diff --git a/Main.tscn b/Main.tscn index ae74486..d0148be 100644 --- a/Main.tscn +++ b/Main.tscn @@ -6,123 +6,55 @@ [ext_resource path="res://StoryNode.cs" type="Script" id=4] [node name="Main" type="Node"] - script = ExtResource( 1 ) [node name="VBoxContainer" type="VBoxContainer" parent="."] - -anchor_left = 0.0 -anchor_top = 0.0 anchor_right = 1.0 anchor_bottom = 1.0 -rect_pivot_offset = Vector2( 0, 0 ) -rect_clip_content = false -mouse_filter = 1 -size_flags_horizontal = 1 -size_flags_vertical = 1 -alignment = 0 [node name="MarginContainer" type="MarginContainer" parent="VBoxContainer"] - -anchor_left = 0.0 -anchor_top = 0.0 -anchor_right = 0.0 -anchor_bottom = 0.0 margin_right = 1024.0 margin_bottom = 576.0 -rect_pivot_offset = Vector2( 0, 0 ) -rect_clip_content = false -mouse_filter = 0 size_flags_horizontal = 3 size_flags_vertical = 3 custom_constants/margin_right = 10 custom_constants/margin_top = 10 custom_constants/margin_left = 10 -_sections_unfolded = [ "Size Flags", "custom_constants" ] [node name="Panel" type="Panel" parent="VBoxContainer/MarginContainer"] - -anchor_left = 0.0 -anchor_top = 0.0 -anchor_right = 0.0 -anchor_bottom = 0.0 margin_left = 10.0 margin_top = 10.0 margin_right = 1014.0 margin_bottom = 576.0 rect_min_size = Vector2( 200, 200 ) -rect_pivot_offset = Vector2( 0, 0 ) -rect_clip_content = false -mouse_filter = 0 size_flags_horizontal = 3 size_flags_vertical = 3 -_sections_unfolded = [ "Grow Direction", "Rect", "Size Flags" ] [node name="TextInterfaceEngine" type="ReferenceRect" parent="VBoxContainer/MarginContainer/Panel"] - -anchor_left = 0.0 -anchor_top = 0.0 anchor_right = 1.0 anchor_bottom = 1.0 -rect_pivot_offset = Vector2( 0, 0 ) -rect_clip_content = false -mouse_filter = 0 -size_flags_horizontal = 1 -size_flags_vertical = 1 script = ExtResource( 2 ) __meta__ = { "_edit_lock_": true, "_editor_icon": ExtResource( 3 ) } -SCROLL_ON_MAX_LINES = true -BREAK_ON_MAX_LINES = false -AUTO_SKIP_WORDS = true -LOG_SKIPPED_LINES = true -SCROLL_SKIPPED_LINES = false -FONT = null -PRINT_INPUT = true -BLINKING_INPUT = true -INPUT_CHARACTERS_LIMIT = -1 [node name="Button" type="Button" parent="VBoxContainer"] - -anchor_left = 0.0 -anchor_top = 0.0 -anchor_right = 0.0 -anchor_bottom = 0.0 margin_left = 491.0 margin_top = 580.0 margin_right = 532.0 margin_bottom = 600.0 -rect_pivot_offset = Vector2( 0, 0 ) -rect_clip_content = false -mouse_filter = 0 +focus_mode = 0 size_flags_horizontal = 4 -size_flags_vertical = 1 -toggle_mode = false enabled_focus_mode = 0 -shortcut = null -group = null text = "Start" -flat = false -_sections_unfolded = [ "Size Flags" ] [node name="StoryNode" type="Node" parent="."] - script = ExtResource( 4 ) - [connection signal="buff_end" from="VBoxContainer/MarginContainer/Panel/TextInterfaceEngine" to="." method="_on_buff_end"] - [connection signal="enter_break" from="VBoxContainer/MarginContainer/Panel/TextInterfaceEngine" to="." method="_on_enter_break"] - [connection signal="input_enter" from="VBoxContainer/MarginContainer/Panel/TextInterfaceEngine" to="." method="_on_input_enter"] - [connection signal="resume_break" from="VBoxContainer/MarginContainer/Panel/TextInterfaceEngine" to="." method="_on_resume_break"] - [connection signal="state_change" from="VBoxContainer/MarginContainer/Panel/TextInterfaceEngine" to="." method="_on_state_change"] - [connection signal="tag_buff" from="VBoxContainer/MarginContainer/Panel/TextInterfaceEngine" to="." method="_on_tag_buff"] - [connection signal="pressed" from="VBoxContainer/Button" to="." method="_on_Button_pressed"] - - diff --git a/StoryNode.cs b/StoryNode.cs index d3de265..61117e4 100644 --- a/StoryNode.cs +++ b/StoryNode.cs @@ -5,15 +5,15 @@ public class StoryNode : Node { - private String input_path = "ink-scripts/Monsieur.ink.json"; + private String input_path = "ink-scripts/Monsieur.ink.json"; private Story _inkStory = null; - public override void _Ready() - { - String text = System.IO.File.ReadAllText(input_path); - _inkStory = new Story(text); - } + public override void _Ready() + { + String text = System.IO.File.ReadAllText(input_path); + _inkStory = new Story(text); + } public void Reset() { @@ -47,10 +47,10 @@ public bool Choose(int i) public String[] GetChoices() { var ret = new String[_inkStory.currentChoices.Count]; - for (int i = 0; i < _inkStory.currentChoices.Count; ++i) { - Choice choice = _inkStory.currentChoices [i]; - ret[i] = choice.text; - } + for (int i = 0; i < _inkStory.currentChoices.Count; ++i) { + Choice choice = _inkStory.currentChoices [i]; + ret[i] = choice.text; + } return ret; } diff --git a/addons/GodotTIE/GodotTIE_icon.png.import b/addons/GodotTIE/GodotTIE_icon.png.import index ce316a7..d8e79d7 100644 --- a/addons/GodotTIE/GodotTIE_icon.png.import +++ b/addons/GodotTIE/GodotTIE_icon.png.import @@ -3,12 +3,21 @@ importer="texture" type="StreamTexture" path="res://.import/GodotTIE_icon.png-06c5755e573090fa68219dcd4c5b75cc.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/GodotTIE/GodotTIE_icon.png" +dest_files=[ "res://.import/GodotTIE_icon.png-06c5755e573090fa68219dcd4c5b75cc.stex" ] [params] compress/mode=0 compress/lossy_quality=0.7 compress/hdr_mode=0 +compress/bptc_ldr=0 compress/normal_map=0 flags/repeat=0 flags/filter=true @@ -18,6 +27,7 @@ flags/srgb=2 process/fix_alpha_border=true process/premult_alpha=false process/HDR_as_SRGB=false +process/invert_color=false stream=false size_limit=0 detect_3d=true diff --git a/default_env.tres b/default_env.tres index ad86b72..0f8c712 100644 --- a/default_env.tres +++ b/default_env.tres @@ -1,101 +1,18 @@ [gd_resource type="Environment" load_steps=2 format=2] [sub_resource type="ProceduralSky" id=1] - radiance_size = 4 sky_top_color = Color( 0.0470588, 0.454902, 0.976471, 1 ) sky_horizon_color = Color( 0.556863, 0.823529, 0.909804, 1 ) sky_curve = 0.25 -sky_energy = 1.0 ground_bottom_color = Color( 0.101961, 0.145098, 0.188235, 1 ) ground_horizon_color = Color( 0.482353, 0.788235, 0.952941, 1 ) ground_curve = 0.01 -ground_energy = 1.0 -sun_color = Color( 1, 1, 1, 1 ) -sun_latitude = 35.0 -sun_longitude = 0.0 -sun_angle_min = 1.0 -sun_angle_max = 100.0 -sun_curve = 0.05 sun_energy = 16.0 -texture_size = 2 [resource] - background_mode = 2 background_sky = SubResource( 1 ) -background_sky_custom_fov = 0.0 -background_color = Color( 0, 0, 0, 1 ) -background_energy = 1.0 -background_canvas_max_layer = 0 -ambient_light_color = Color( 0, 0, 0, 1 ) -ambient_light_energy = 1.0 -ambient_light_sky_contribution = 1.0 -fog_enabled = false -fog_color = Color( 0.5, 0.6, 0.7, 1 ) -fog_sun_color = Color( 1, 0.9, 0.7, 1 ) -fog_sun_amount = 0.0 -fog_depth_enabled = true -fog_depth_begin = 10.0 -fog_depth_curve = 1.0 -fog_transmit_enabled = false -fog_transmit_curve = 1.0 -fog_height_enabled = false fog_height_min = 0.0 fog_height_max = 100.0 -fog_height_curve = 1.0 -tonemap_mode = 0 -tonemap_exposure = 1.0 -tonemap_white = 1.0 -auto_exposure_enabled = false -auto_exposure_scale = 0.4 -auto_exposure_min_luma = 0.05 -auto_exposure_max_luma = 8.0 -auto_exposure_speed = 0.5 -ss_reflections_enabled = false -ss_reflections_max_steps = 64 -ss_reflections_fade_in = 0.15 -ss_reflections_fade_out = 2.0 -ss_reflections_depth_tolerance = 0.2 -ss_reflections_roughness = true -ssao_enabled = false -ssao_radius = 1.0 -ssao_intensity = 1.0 -ssao_radius2 = 0.0 -ssao_intensity2 = 1.0 -ssao_bias = 0.01 -ssao_light_affect = 0.0 -ssao_color = Color( 0, 0, 0, 1 ) ssao_quality = 0 -ssao_blur = 3 -ssao_edge_sharpness = 4.0 -dof_blur_far_enabled = false -dof_blur_far_distance = 10.0 -dof_blur_far_transition = 5.0 -dof_blur_far_amount = 0.1 -dof_blur_far_quality = 1 -dof_blur_near_enabled = false -dof_blur_near_distance = 2.0 -dof_blur_near_transition = 1.0 -dof_blur_near_amount = 0.1 -dof_blur_near_quality = 1 -glow_enabled = false -glow_levels/1 = false -glow_levels/2 = false -glow_levels/3 = true -glow_levels/4 = false -glow_levels/5 = true -glow_levels/6 = false -glow_levels/7 = false -glow_intensity = 0.8 -glow_strength = 1.0 -glow_bloom = 0.0 -glow_blend_mode = 2 -glow_hdr_threshold = 1.0 -glow_hdr_scale = 2.0 -glow_bicubic_upscale = false -adjustment_enabled = false -adjustment_brightness = 1.0 -adjustment_contrast = 1.0 -adjustment_saturation = 1.0 - diff --git a/icon.png.import b/icon.png.import index 42e94a3..96cbf46 100644 --- a/icon.png.import +++ b/icon.png.import @@ -3,12 +3,21 @@ importer="texture" type="StreamTexture" path="res://.import/icon.png-487276ed1e3a0c39cad0279d744ee560.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://icon.png" +dest_files=[ "res://.import/icon.png-487276ed1e3a0c39cad0279d744ee560.stex" ] [params] compress/mode=0 compress/lossy_quality=0.7 compress/hdr_mode=0 +compress/bptc_ldr=0 compress/normal_map=0 flags/repeat=0 flags/filter=true @@ -18,6 +27,7 @@ flags/srgb=2 process/fix_alpha_border=true process/premult_alpha=false process/HDR_as_SRGB=false +process/invert_color=false stream=false size_limit=0 detect_3d=true diff --git a/ink-engine-runtime/CallStack.cs b/ink-engine-runtime/CallStack.cs index f5adf34..74e11c7 100644 --- a/ink-engine-runtime/CallStack.cs +++ b/ink-engine-runtime/CallStack.cs @@ -1,103 +1,92 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using System.Diagnostics; namespace Ink.Runtime { - internal class CallStack - { - internal class Element - { - public Container currentContainer; - public int currentContentIndex; - - public bool inExpressionEvaluation; - public Dictionary temporaryVariables; - public PushPopType type; - - public Runtime.Object currentObject { - get { - if (currentContainer && currentContentIndex < currentContainer.content.Count) { - return currentContainer.content [currentContentIndex]; - } - - return null; - } - set { - var currentObj = value; - if (currentObj == null) { - currentContainer = null; - currentContentIndex = 0; - return; - } - - currentContainer = currentObj.parent as Container; - if (currentContainer != null) - currentContentIndex = currentContainer.content.IndexOf (currentObj); - - // Two reasons why the above operation might not work: - // - currentObj is already the root container - // - currentObj is a named container rather than being an object at an index - if (currentContainer == null || currentContentIndex == -1) { - currentContainer = currentObj as Container; - currentContentIndex = 0; - } - } - } - - public Element(PushPopType type, Container container, int contentIndex, bool inExpressionEvaluation = false) { - this.currentContainer = container; - this.currentContentIndex = contentIndex; - this.inExpressionEvaluation = inExpressionEvaluation; - this.temporaryVariables = new Dictionary(); - this.type = type; - } - - public Element Copy() - { - var copy = new Element (this.type, this.currentContainer, this.currentContentIndex, this.inExpressionEvaluation); - copy.temporaryVariables = new Dictionary(this.temporaryVariables); - return copy; - } - } - - internal class Thread - { - public List callstack; - public int threadIndex; - public Runtime.Object previousContentObject; - - public Thread() { - callstack = new List(); - } + internal class CallStack + { + internal class Element + { + public Pointer currentPointer; + + public bool inExpressionEvaluation; + public Dictionary temporaryVariables; + public PushPopType type; + + // When this callstack element is actually a function evaluation called from the game, + // we need to keep track of the size of the evaluation stack when it was called + // so that we know whether there was any return value. + public int evaluationStackHeightWhenPushed; + + // When functions are called, we trim whitespace from the start and end of what + // they generate, so we make sure know where the function's start and end are. + public int functionStartInOuputStream; + + public Element(PushPopType type, Pointer pointer, bool inExpressionEvaluation = false) { + this.currentPointer = pointer; + this.inExpressionEvaluation = inExpressionEvaluation; + this.temporaryVariables = new Dictionary(); + this.type = type; + } + + public Element Copy() + { + var copy = new Element (this.type, currentPointer, this.inExpressionEvaluation); + copy.temporaryVariables = new Dictionary(this.temporaryVariables); + copy.evaluationStackHeightWhenPushed = evaluationStackHeightWhenPushed; + copy.functionStartInOuputStream = functionStartInOuputStream; + return copy; + } + } + + internal class Thread + { + public List callstack; + public int threadIndex; + public Pointer previousPointer; + + public Thread() { + callstack = new List(); + } public Thread(Dictionary jThreadObj, Story storyContext) : this() { - threadIndex = (int) jThreadObj ["threadIndex"]; + threadIndex = (int) jThreadObj ["threadIndex"]; List jThreadCallstack = (List) jThreadObj ["callstack"]; foreach (object jElTok in jThreadCallstack) { var jElementObj = (Dictionary)jElTok; - PushPopType pushPopType = (PushPopType)(int)jElementObj ["type"]; + PushPopType pushPopType = (PushPopType)(int)jElementObj ["type"]; - Container currentContainer = null; - int contentIndex = 0; + Pointer pointer = Pointer.Null; string currentContainerPathStr = null; object currentContainerPathStrToken; if (jElementObj.TryGetValue ("cPath", out currentContainerPathStrToken)) { currentContainerPathStr = currentContainerPathStrToken.ToString (); - currentContainer = storyContext.ContentAtPath (new Path(currentContainerPathStr)) as Container; - contentIndex = (int) jElementObj ["idx"]; + + var threadPointerResult = storyContext.ContentAtPath (new Path (currentContainerPathStr)); + pointer.container = threadPointerResult.container; + pointer.index = (int)jElementObj ["idx"]; + + if (threadPointerResult.obj == null) + throw new System.Exception ("When loading state, internal story location couldn't be found: " + currentContainerPathStr + ". Has the story changed since this save data was created?"); + else if (threadPointerResult.approximate) + storyContext.Warning ("When loading state, exact internal story location couldn't be found: '" + currentContainerPathStr + "', so it was approximated to '"+pointer.container.path.ToString()+"' to recover. Has the story changed since this save data was created?"); } - bool inExpressionEvaluation = (bool)jElementObj ["exp"]; + bool inExpressionEvaluation = (bool)jElementObj ["exp"]; - var el = new Element (pushPopType, currentContainer, contentIndex, inExpressionEvaluation); + var el = new Element (pushPopType, pointer, inExpressionEvaluation); - var jObjTemps = (Dictionary) jElementObj ["temp"]; - el.temporaryVariables = Json.JObjectToDictionaryRuntimeObjs (jObjTemps); + object temps; + if ( jElementObj.TryGetValue("temp", out temps) ) { + el.temporaryVariables = Json.JObjectToDictionaryRuntimeObjs((Dictionary)temps); + } else { + el.temporaryVariables.Clear(); + } callstack.Add (el); } @@ -105,53 +94,66 @@ public Thread(Dictionary jThreadObj, Story storyContext) : this( object prevContentObjPath; if( jThreadObj.TryGetValue("previousContentObject", out prevContentObjPath) ) { var prevPath = new Path((string)prevContentObjPath); - previousContentObject = storyContext.ContentAtPath(prevPath); - } + previousPointer = storyContext.PointerAtPath(prevPath); + } } - public Thread Copy() { - var copy = new Thread (); - copy.threadIndex = threadIndex; - foreach(var e in callstack) { - copy.callstack.Add(e.Copy()); - } - copy.previousContentObject = previousContentObject; - return copy; - } - - public Dictionary jsonToken { - get { - var threadJObj = new Dictionary (); - - var jThreadCallstack = new List (); - foreach (CallStack.Element el in callstack) { - var jObj = new Dictionary (); - if (el.currentContainer) { - jObj ["cPath"] = el.currentContainer.path.componentsString; - jObj ["idx"] = el.currentContentIndex; - } - jObj ["exp"] = el.inExpressionEvaluation; - jObj ["type"] = (int) el.type; - jObj ["temp"] = Json.DictionaryRuntimeObjsToJObject (el.temporaryVariables); - jThreadCallstack.Add (jObj); + public Thread Copy() { + var copy = new Thread (); + copy.threadIndex = threadIndex; + foreach(var e in callstack) { + copy.callstack.Add(e.Copy()); + } + copy.previousPointer = previousPointer; + return copy; + } + + public void WriteJson(SimpleJson.Writer writer) + { + writer.WriteObjectStart(); + + // callstack + writer.WritePropertyStart("callstack"); + writer.WriteArrayStart(); + foreach (CallStack.Element el in callstack) + { + writer.WriteObjectStart(); + if(!el.currentPointer.isNull) { + writer.WriteProperty("cPath", el.currentPointer.container.path.componentsString); + writer.WriteProperty("idx", el.currentPointer.index); + } + + writer.WriteProperty("exp", el.inExpressionEvaluation); + writer.WriteProperty("type", (int)el.type); + + if(el.temporaryVariables.Count > 0) { + writer.WritePropertyStart("temp"); + Json.WriteDictionaryRuntimeObjs(writer, el.temporaryVariables); + writer.WritePropertyEnd(); } - threadJObj ["callstack"] = jThreadCallstack; - threadJObj ["threadIndex"] = threadIndex; + writer.WriteObjectEnd(); + } + writer.WriteArrayEnd(); + writer.WritePropertyEnd(); - if (previousContentObject != null) - threadJObj ["previousContentObject"] = previousContentObject.path.ToString(); + // threadIndex + writer.WriteProperty("threadIndex", threadIndex); - return threadJObj; + if (!previousPointer.isNull) + { + writer.WriteProperty("previousContentObject", previousPointer.Resolve().path.ToString()); } + + writer.WriteObjectEnd(); } - } + } - public List elements { - get { - return callStack; - } - } + public List elements { + get { + return callStack; + } + } public int depth { get { @@ -159,204 +161,283 @@ public int depth { } } - public Element currentElement { - get { - return callStack [callStack.Count - 1]; - } - } - - public int currentElementIndex { - get { - return callStack.Count - 1; - } - } - - public Thread currentThread - { - get { - return _threads [_threads.Count - 1]; - } - set { - Debug.Assert (_threads.Count == 1, "Shouldn't be directly setting the current thread when we have a stack of them"); - _threads.Clear (); - _threads.Add (value); - } - } - - public bool canPop { - get { - return callStack.Count > 1; - } - } - - public CallStack (Container rootContentContainer) - { - _threads = new List (); - _threads.Add (new Thread ()); - - _threads [0].callstack.Add (new Element (PushPopType.Tunnel, rootContentContainer, 0)); - } - - public CallStack(CallStack toCopy) - { - _threads = new List (); - foreach (var otherThread in toCopy._threads) { - _threads.Add (otherThread.Copy ()); - } - } - - // Unfortunately it's not possible to implement jsonToken since - // the setter needs to take a Story as a context in order to - // look up objects from paths for currentContainer within elements. - public void SetJsonToken(Dictionary jObject, Story storyContext) - { - _threads.Clear (); - - var jThreads = (List) jObject ["threads"]; - - foreach (object jThreadTok in jThreads) { - var jThreadObj = (Dictionary)jThreadTok; - var thread = new Thread (jThreadObj, storyContext); - _threads.Add (thread); - } - - _threadCounter = (int)jObject ["threadCounter"]; - } - - // See above for why we can't implement jsonToken - public Dictionary GetJsonToken() { - - var jObject = new Dictionary (); - - var jThreads = new List (); - foreach (CallStack.Thread thread in _threads) { - jThreads.Add (thread.jsonToken); - } - - jObject ["threads"] = jThreads; - jObject ["threadCounter"] = _threadCounter; - - return jObject; - } - - public void PushThread() - { - var newThread = currentThread.Copy (); - _threadCounter++; - newThread.threadIndex = _threadCounter; - _threads.Add (newThread); - } - - public void PopThread() - { - if (canPopThread) { - _threads.Remove (currentThread); - } else { + public Element currentElement { + get { + var thread = _threads [_threads.Count - 1]; + var cs = thread.callstack; + return cs [cs.Count - 1]; + } + } + + public int currentElementIndex { + get { + return callStack.Count - 1; + } + } + + public Thread currentThread + { + get { + return _threads [_threads.Count - 1]; + } + set { + Debug.Assert (_threads.Count == 1, "Shouldn't be directly setting the current thread when we have a stack of them"); + _threads.Clear (); + _threads.Add (value); + } + } + + public bool canPop { + get { + return callStack.Count > 1; + } + } + + public CallStack (Story storyContext) + { + _startOfRoot = Pointer.StartOf(storyContext.rootContentContainer); + Reset(); + } + + + public CallStack(CallStack toCopy) + { + _threads = new List (); + foreach (var otherThread in toCopy._threads) { + _threads.Add (otherThread.Copy ()); + } + _threadCounter = toCopy._threadCounter; + _startOfRoot = toCopy._startOfRoot; + } + + public void Reset() + { + _threads = new List(); + _threads.Add(new Thread()); + + _threads[0].callstack.Add(new Element(PushPopType.Tunnel, _startOfRoot)); + } + + + // Unfortunately it's not possible to implement jsonToken since + // the setter needs to take a Story as a context in order to + // look up objects from paths for currentContainer within elements. + public void SetJsonToken(Dictionary jObject, Story storyContext) + { + _threads.Clear (); + + var jThreads = (List) jObject ["threads"]; + + foreach (object jThreadTok in jThreads) { + var jThreadObj = (Dictionary)jThreadTok; + var thread = new Thread (jThreadObj, storyContext); + _threads.Add (thread); + } + + _threadCounter = (int)jObject ["threadCounter"]; + _startOfRoot = Pointer.StartOf(storyContext.rootContentContainer); + } + + public void WriteJson(SimpleJson.Writer w) + { + w.WriteObject(writer => + { + writer.WritePropertyStart("threads"); + { + writer.WriteArrayStart(); + + foreach (CallStack.Thread thread in _threads) + { + thread.WriteJson(writer); + } + + writer.WriteArrayEnd(); + } + writer.WritePropertyEnd(); + + writer.WritePropertyStart("threadCounter"); + { + writer.Write(_threadCounter); + } + writer.WritePropertyEnd(); + }); + + } + + public void PushThread() + { + var newThread = currentThread.Copy (); + _threadCounter++; + newThread.threadIndex = _threadCounter; + _threads.Add (newThread); + } + + public Thread ForkThread() + { + var forkedThread = currentThread.Copy(); + _threadCounter++; + forkedThread.threadIndex = _threadCounter; + return forkedThread; + } + + public void PopThread() + { + if (canPopThread) { + _threads.Remove (currentThread); + } else { throw new System.Exception("Can't pop thread"); - } - } - - public bool canPopThread - { - get { - return _threads.Count > 1; - } - } - - public void Push(PushPopType type) - { - // When pushing to callstack, maintain the current content path, but jump out of expressions by default - callStack.Add (new Element(type, currentElement.currentContainer, currentElement.currentContentIndex, inExpressionEvaluation: false)); - } - - public bool CanPop(PushPopType? type = null) { - - if (!canPop) - return false; - - if (type == null) - return true; - - return currentElement.type == type; - } - - public void Pop(PushPopType? type = null) - { - if (CanPop (type)) { - callStack.RemoveAt (callStack.Count - 1); - return; - } else { + } + } + + public bool canPopThread + { + get { + return _threads.Count > 1 && !elementIsEvaluateFromGame; + } + } + + public bool elementIsEvaluateFromGame + { + get { + return currentElement.type == PushPopType.FunctionEvaluationFromGame; + } + } + + public void Push(PushPopType type, int externalEvaluationStackHeight = 0, int outputStreamLengthWithPushed = 0) + { + // When pushing to callstack, maintain the current content path, but jump out of expressions by default + var element = new Element ( + type, + currentElement.currentPointer, + inExpressionEvaluation: false + ); + + element.evaluationStackHeightWhenPushed = externalEvaluationStackHeight; + element.functionStartInOuputStream = outputStreamLengthWithPushed; + + callStack.Add (element); + } + + public bool CanPop(PushPopType? type = null) { + + if (!canPop) + return false; + + if (type == null) + return true; + + return currentElement.type == type; + } + + public void Pop(PushPopType? type = null) + { + if (CanPop (type)) { + callStack.RemoveAt (callStack.Count - 1); + return; + } else { throw new System.Exception("Mismatched push/pop in Callstack"); - } - } - - // Get variable value, dereferencing a variable pointer if necessary - public Runtime.Object GetTemporaryVariableWithName(string name, int contextIndex = -1) - { - if (contextIndex == -1) - contextIndex = currentElementIndex+1; - - Runtime.Object varValue = null; - - var contextElement = callStack [contextIndex-1]; - - if (contextElement.temporaryVariables.TryGetValue (name, out varValue)) { - return varValue; - } else { - return null; - } - } - - public void SetTemporaryVariable(string name, Runtime.Object value, bool declareNew, int contextIndex = -1) - { - if (contextIndex == -1) - contextIndex = currentElementIndex+1; - - var contextElement = callStack [contextIndex-1]; - - if (!declareNew && !contextElement.temporaryVariables.ContainsKey(name)) { - throw new StoryException ("Could not find temporary variable to set: " + name); - } - - Runtime.Object oldValue; - if( contextElement.temporaryVariables.TryGetValue(name, out oldValue) ) - ListValue.RetainListOriginsForAssignment (oldValue, value); - - contextElement.temporaryVariables [name] = value; - } - - // Find the most appropriate context for this variable. - // Are we referencing a temporary or global variable? - // Note that the compiler will have warned us about possible conflicts, - // so anything that happens here should be safe! - public int ContextForVariableNamed(string name) - { - // Current temporary context? - // (Shouldn't attempt to access contexts higher in the callstack.) - if (currentElement.temporaryVariables.ContainsKey (name)) { - return currentElementIndex+1; - } - - // Global - else { - return 0; - } - } - - public Thread ThreadWithIndex(int index) - { - return _threads.Find (t => t.threadIndex == index); - } - - private List callStack - { - get { - return currentThread.callstack; - } - } - - List _threads; - int _threadCounter; - } + } + } + + // Get variable value, dereferencing a variable pointer if necessary + public Runtime.Object GetTemporaryVariableWithName(string name, int contextIndex = -1) + { + if (contextIndex == -1) + contextIndex = currentElementIndex+1; + + Runtime.Object varValue = null; + + var contextElement = callStack [contextIndex-1]; + + if (contextElement.temporaryVariables.TryGetValue (name, out varValue)) { + return varValue; + } else { + return null; + } + } + + public void SetTemporaryVariable(string name, Runtime.Object value, bool declareNew, int contextIndex = -1) + { + if (contextIndex == -1) + contextIndex = currentElementIndex+1; + + var contextElement = callStack [contextIndex-1]; + + if (!declareNew && !contextElement.temporaryVariables.ContainsKey(name)) { + throw new StoryException ("Could not find temporary variable to set: " + name); + } + + Runtime.Object oldValue; + if( contextElement.temporaryVariables.TryGetValue(name, out oldValue) ) + ListValue.RetainListOriginsForAssignment (oldValue, value); + + contextElement.temporaryVariables [name] = value; + } + + // Find the most appropriate context for this variable. + // Are we referencing a temporary or global variable? + // Note that the compiler will have warned us about possible conflicts, + // so anything that happens here should be safe! + public int ContextForVariableNamed(string name) + { + // Current temporary context? + // (Shouldn't attempt to access contexts higher in the callstack.) + if (currentElement.temporaryVariables.ContainsKey (name)) { + return currentElementIndex+1; + } + + // Global + else { + return 0; + } + } + + public Thread ThreadWithIndex(int index) + { + return _threads.Find (t => t.threadIndex == index); + } + + private List callStack + { + get { + return currentThread.callstack; + } + } + + internal string callStackTrace { + get { + var sb = new System.Text.StringBuilder(); + + for(int t=0; t<_threads.Count; t++) { + + var thread = _threads[t]; + var isCurrent = (t == _threads.Count-1); + sb.AppendFormat("=== THREAD {0}/{1} {2}===\n", (t+1), _threads.Count, (isCurrent ? "(current) ":"")); + + for(int i=0; i"); + } + } + } + + + return sb.ToString(); + } + } + + List _threads; + int _threadCounter; + Pointer _startOfRoot; + } } diff --git a/ink-engine-runtime/Choice.cs b/ink-engine-runtime/Choice.cs index 0f45c41..7f3abf1 100644 --- a/ink-engine-runtime/Choice.cs +++ b/ink-engine-runtime/Choice.cs @@ -18,17 +18,20 @@ public class Choice : Runtime.Object /// The target path that the Story should be diverted to if /// this Choice is chosen. /// - public string pathStringOnChoice { get { return choicePoint.pathStringOnChoice; } } + public string pathStringOnChoice { + get { + return targetPath.ToString (); + } + set { + targetPath = new Path (value); + } + } /// /// Get the path to the original choice point - where was this choice defined in the story? /// /// A dot separated path into the story data. - public string sourcePath { - get { - return choicePoint.path.componentsString; - } - } + public string sourcePath; /// /// The original index into currentChoices list on the Story when @@ -36,23 +39,16 @@ public string sourcePath { /// public int index { get; set; } - internal ChoicePoint choicePoint { get; set; } + internal Path targetPath; + internal CallStack.Thread threadAtGeneration { get; set; } internal int originalThreadIndex; - // Only used temporarily for loading/saving from JSON - internal string originalChoicePath; - + internal bool isInvisibleDefault; internal Choice() { } - - internal Choice (ChoicePoint choice) - { - this.choicePoint = choice; - } - } } diff --git a/ink-engine-runtime/ChoicePoint.cs b/ink-engine-runtime/ChoicePoint.cs index 2f4907b..5a23653 100644 --- a/ink-engine-runtime/ChoicePoint.cs +++ b/ink-engine-runtime/ChoicePoint.cs @@ -28,7 +28,7 @@ internal Path pathOnChoice { internal Container choiceTarget { get { - return this.ResolvePath (_pathOnChoice) as Container; + return this.ResolvePath (_pathOnChoice).container; } } @@ -79,7 +79,7 @@ public override string ToString () string targetString = pathOnChoice.ToString (); if (targetLineNum != null) { - targetString = " line " + targetLineNum; + targetString = " line " + targetLineNum + "("+targetString+")"; } return "Choice: -> " + targetString; diff --git a/ink-engine-runtime/Container.cs b/ink-engine-runtime/Container.cs index 3055534..9a1fe08 100644 --- a/ink-engine-runtime/Container.cs +++ b/ink-engine-runtime/Container.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Diagnostics; using System.Collections.Generic; using System.Linq; @@ -119,15 +119,15 @@ public Path pathToFirstLeafContent Path internalPathToFirstLeafContent { get { - var path = new Path (); + var components = new List(); var container = this; while (container != null) { if (container.content.Count > 0) { - path.components.Add (new Path.Component (0)); + components.Add (new Path.Component (0)); container = container.content [0] as Container; } } - return path; + return new Path(components); } } @@ -221,30 +221,48 @@ protected Runtime.Object ContentWithPathComponent(Path.Component component) if (namedContent.TryGetValue (component.name, out foundContent)) { return (Runtime.Object)foundContent; } else { - throw new StoryException ("Content '"+component.name+"' not found at path: '"+this.path+"'"); + return null; } } } - public Runtime.Object ContentAtPath(Path path, int partialPathLength = -1) + public SearchResult ContentAtPath(Path path, int partialPathStart = 0, int partialPathLength = -1) { if (partialPathLength == -1) - partialPathLength = path.components.Count; - + partialPathLength = path.length; + + var result = new SearchResult (); + result.approximate = false; + Container currentContainer = this; Runtime.Object currentObj = this; - for (int i = 0; i < partialPathLength; ++i) { - var comp = path.components [i]; - if (currentContainer == null) - throw new System.Exception ("Path continued, but previous object wasn't a container: " + currentObj); - currentObj = currentContainer.ContentWithPathComponent(comp); - currentContainer = currentObj as Container; + for (int i = partialPathStart; i < partialPathLength; ++i) { + var comp = path.GetComponent(i); + + // Path component was wrong type + if (currentContainer == null) { + result.approximate = true; + break; + } + + var foundObj = currentContainer.ContentWithPathComponent(comp); + + // Couldn't resolve entire path? + if (foundObj == null) { + result.approximate = true; + break; + } + + currentObj = foundObj; + currentContainer = foundObj as Container; } - return currentObj; + result.obj = currentObj; + + return result; } - + public void BuildStringOfHierarchy(StringBuilder sb, int indentation, Runtime.Object pointedObj) { Action appendIndentation = () => { diff --git a/ink-engine-runtime/ControlCommand.cs b/ink-engine-runtime/ControlCommand.cs index 8c66631..cb2af84 100644 --- a/ink-engine-runtime/ControlCommand.cs +++ b/ink-engine-runtime/ControlCommand.cs @@ -18,6 +18,7 @@ public enum CommandType EndString, NoOp, ChoiceCount, + Turns, TurnsSince, ReadCount, Random, @@ -29,6 +30,7 @@ public enum CommandType End, ListFromInt, ListRange, + ListRandom, //---- TOTAL_VALUES } @@ -101,6 +103,11 @@ public static ControlCommand ChoiceCount() { return new ControlCommand(CommandType.ChoiceCount); } + public static ControlCommand Turns () + { + return new ControlCommand (CommandType.Turns); + } + public static ControlCommand TurnsSince() { return new ControlCommand(CommandType.TurnsSince); } @@ -149,6 +156,11 @@ public static ControlCommand ListRange () return new ControlCommand (CommandType.ListRange); } + public static ControlCommand ListRandom () + { + return new ControlCommand (CommandType.ListRandom); + } + public override string ToString () { return commandType.ToString(); diff --git a/ink-engine-runtime/Divert.cs b/ink-engine-runtime/Divert.cs index 7b8c8cf..8358869 100644 --- a/ink-engine-runtime/Divert.cs +++ b/ink-engine-runtime/Divert.cs @@ -8,7 +8,7 @@ public Path targetPath { get { // Resolve any relative paths to global ones as we come across them if (_targetPath != null && _targetPath.isRelative) { - var targetObj = targetContent; + var targetObj = targetPointer.Resolve(); if (targetObj) { _targetPath = targetObj.path; } @@ -17,21 +17,28 @@ public Path targetPath { } set { _targetPath = value; - _targetContent = null; + _targetPointer = Pointer.Null; } } Path _targetPath; - public Runtime.Object targetContent { + public Pointer targetPointer { get { - if (_targetContent == null) { - _targetContent = ResolvePath (_targetPath); - } + if (_targetPointer.isNull) { + var targetObj = ResolvePath (_targetPath).obj; - return _targetContent; + if (_targetPath.lastComponent.isIndex) { + _targetPointer.container = targetObj.parent as Container; + _targetPointer.index = _targetPath.lastComponent.index; + } else { + _targetPointer = Pointer.StartOf (targetObj as Container); + } + } + return _targetPointer; } } - Runtime.Object _targetContent; + Pointer _targetPointer; + public string targetPathString { get { diff --git a/ink-engine-runtime/Glue.cs b/ink-engine-runtime/Glue.cs index 9b12dbc..3fbf22d 100644 --- a/ink-engine-runtime/Glue.cs +++ b/ink-engine-runtime/Glue.cs @@ -1,47 +1,12 @@ namespace Ink.Runtime { - internal enum GlueType - { - Bidirectional, - Left, - Right - } - internal class Glue : Runtime.Object { - public GlueType glueType { get; set; } - - public bool isLeft { - get { - return glueType == GlueType.Left; - } - } - - public bool isBi { - get { - return glueType == GlueType.Bidirectional; - } - } - - public bool isRight { - get { - return glueType == GlueType.Right; - } - } - - public Glue(GlueType type) { - glueType = type; - } + public Glue() { } public override string ToString () { - switch (glueType) { - case GlueType.Bidirectional: return "BidirGlue"; - case GlueType.Left: return "LeftGlue"; - case GlueType.Right: return "RightGlue"; - } - - return "UnexpectedGlueType"; + return "Glue"; } } } diff --git a/ink-engine-runtime/InkList.cs b/ink-engine-runtime/InkList.cs index 25000f8..47216fd 100644 --- a/ink-engine-runtime/InkList.cs +++ b/ink-engine-runtime/InkList.cs @@ -126,7 +126,7 @@ public InkList (string singleOriginListName, Story originStory) SetInitialOriginName (singleOriginListName); ListDefinition def; - if (originStory.listDefinitions.TryGetDefinition (singleOriginListName, out def)) + if (originStory.listDefinitions.TryListGetDefinition (singleOriginListName, out def)) origins = new List { def }; else throw new System.Exception ("InkList origin could not be found in story when constructing new list: " + singleOriginListName); @@ -135,7 +135,22 @@ public InkList (string singleOriginListName, Story originStory) internal InkList (KeyValuePair singleElement) { Add (singleElement.Key, singleElement.Value); - } + } + + /// + /// Converts a string to an ink list and returns for use in the story. + /// + /// InkList created from string list item + /// Item key. + /// Origin story. + public static InkList FromString(string myListItem, Story originStory) { + var listValue = originStory.listDefinitions.FindSingleItemListWithName (myListItem); + if (listValue) + return new InkList (listValue.value); + else + throw new System.Exception ("Could not find the InkListItem from the string '" + myListItem + "' to create an InkList because it doesn't exist in the original list definition in ink."); + } + /// /// Adds the given item to the ink list. Note that the item must come from a list definition that @@ -447,6 +462,53 @@ internal InkList MinAsList () return new InkList (); } + /// + /// Returns a sublist with the elements given the minimum and maxmimum bounds. + /// The bounds can either be ints which are indices into the entire (sorted) list, + /// or they can be InkLists themselves. These are intended to be single-item lists so + /// you can specify the upper and lower bounds. If you pass in multi-item lists, it'll + /// use the minimum and maximum items in those lists respectively. + /// WARNING: Calling this method requires a full sort of all the elements in the list. + /// + public InkList ListWithSubRange(object minBound, object maxBound) + { + if (this.Count == 0) return new InkList(); + + var ordered = orderedItems; + + int minValue = 0; + int maxValue = int.MaxValue; + + if (minBound is int) + { + minValue = (int)minBound; + } + + else + { + if( minBound is InkList && ((InkList)minBound).Count > 0 ) + minValue = ((InkList)minBound).minItem.Value; + } + + if (maxBound is int) + maxValue = (int)maxBound; + else + { + if (minBound is InkList && ((InkList)minBound).Count > 0) + maxValue = ((InkList)maxBound).maxItem.Value; + } + + var subList = new InkList(); + subList.SetInitialOriginNames(originNames); + foreach(var item in ordered) { + if( item.Value >= minValue && item.Value <= maxValue ) { + subList.Add(item.Key, item.Value); + } + } + + return subList; + } + /// /// Returns true if the passed object is also an ink list that contains /// the same items as the current list, false otherwise. @@ -476,15 +538,29 @@ public override int GetHashCode () return ownHash; } + List> orderedItems { + get { + var ordered = new List>(); + ordered.AddRange(this); + ordered.Sort((x, y) => { + // Ensure consistent ordering of mixed lists. + if( x.Value == y.Value ) { + return x.Key.originName.CompareTo(y.Key.originName); + } else { + return x.Value.CompareTo(y.Value); + } + }); + return ordered; + } + } + /// /// Returns a string in the form "a, b, c" with the names of the items in the list, without /// the origin list definition names. Equivalent to writing {list} in ink. /// public override string ToString () { - var ordered = new List> (); - ordered.AddRange (this); - ordered.Sort ((x, y) => x.Value.CompareTo (y.Value)); + var ordered = orderedItems; var sb = new StringBuilder (); for (int i = 0; i < ordered.Count; i++) { diff --git a/ink-engine-runtime/JsonSerialisation.cs b/ink-engine-runtime/JsonSerialisation.cs index 0ddc86b..2af17ed 100644 --- a/ink-engine-runtime/JsonSerialisation.cs +++ b/ink-engine-runtime/JsonSerialisation.cs @@ -6,15 +6,6 @@ namespace Ink.Runtime { internal static class Json { - public static List ListToJArray(List serialisables) where T : Runtime.Object - { - var jArray = new List (); - foreach (var s in serialisables) { - jArray.Add (RuntimeObjectToJToken(s)); - } - return jArray; - } - public static List JArrayToRuntimeObjList(List jArray, bool skipLast=false) where T : Runtime.Object { int count = jArray.Count; @@ -37,17 +28,232 @@ public static List JArrayToRuntimeObjList(List jArray, bool skipLa return JArrayToRuntimeObjList (jArray, skipLast); } - public static Dictionary DictionaryRuntimeObjsToJObject(Dictionary dictionary) + public static void WriteDictionaryRuntimeObjs(SimpleJson.Writer writer, Dictionary dictionary) { - var jsonObj = new Dictionary (); + writer.WriteObjectStart(); + foreach(var keyVal in dictionary) { + writer.WritePropertyStart(keyVal.Key); + WriteRuntimeObject(writer, keyVal.Value); + writer.WritePropertyEnd(); + } + writer.WriteObjectEnd(); + } - foreach (var keyVal in dictionary) { - var runtimeObj = keyVal.Value as Runtime.Object; - if (runtimeObj != null) - jsonObj [keyVal.Key] = RuntimeObjectToJToken(runtimeObj); + + public static void WriteListRuntimeObjs(SimpleJson.Writer writer, List list) + { + writer.WriteArrayStart(); + foreach (var val in list) + { + WriteRuntimeObject(writer, val); } + writer.WriteArrayEnd(); + } - return jsonObj; + public static void WriteIntDictionary(SimpleJson.Writer writer, Dictionary dict) + { + writer.WriteObjectStart(); + foreach (var keyVal in dict) + writer.WriteProperty(keyVal.Key, keyVal.Value); + writer.WriteObjectEnd(); + } + + public static void WriteRuntimeObject(SimpleJson.Writer writer, Runtime.Object obj) + { + var container = obj as Container; + if (container) { + WriteRuntimeContainer(writer, container); + return; + } + + var divert = obj as Divert; + if (divert) + { + string divTypeKey = "->"; + if (divert.isExternal) + divTypeKey = "x()"; + else if (divert.pushesToStack) + { + if (divert.stackPushType == PushPopType.Function) + divTypeKey = "f()"; + else if (divert.stackPushType == PushPopType.Tunnel) + divTypeKey = "->t->"; + } + + string targetStr; + if (divert.hasVariableTarget) + targetStr = divert.variableDivertName; + else + targetStr = divert.targetPathString; + + writer.WriteObjectStart(); + + writer.WriteProperty(divTypeKey, targetStr); + + if (divert.hasVariableTarget) + writer.WriteProperty("var", true); + + if (divert.isConditional) + writer.WriteProperty("c", true); + + if (divert.externalArgs > 0) + writer.WriteProperty("exArgs", divert.externalArgs); + + writer.WriteObjectEnd(); + return; + } + + var choicePoint = obj as ChoicePoint; + if (choicePoint) + { + writer.WriteObjectStart(); + writer.WriteProperty("*", choicePoint.pathStringOnChoice); + writer.WriteProperty("flg", choicePoint.flags); + writer.WriteObjectEnd(); + return; + } + + var intVal = obj as IntValue; + if (intVal) { + writer.Write(intVal.value); + return; + } + + var floatVal = obj as FloatValue; + if (floatVal) { + writer.Write(floatVal.value); + return; + } + + var strVal = obj as StringValue; + if (strVal) + { + if (strVal.isNewline) + writer.Write("\\n", escape:false); + else { + writer.WriteStringStart(); + writer.WriteStringInner("^"); + writer.WriteStringInner(strVal.value); + writer.WriteStringEnd(); + } + return; + } + + var listVal = obj as ListValue; + if (listVal) + { + WriteInkList(writer, listVal); + return; + } + + var divTargetVal = obj as DivertTargetValue; + if (divTargetVal) + { + writer.WriteObjectStart(); + writer.WriteProperty("^->", divTargetVal.value.componentsString); + writer.WriteObjectEnd(); + return; + } + + var varPtrVal = obj as VariablePointerValue; + if (varPtrVal) + { + writer.WriteObjectStart(); + writer.WriteProperty("^var", varPtrVal.value); + writer.WriteProperty("ci", varPtrVal.contextIndex); + writer.WriteObjectEnd(); + return; + } + + var glue = obj as Runtime.Glue; + if (glue) { + writer.Write("<>"); + return; + } + + var controlCmd = obj as ControlCommand; + if (controlCmd) + { + writer.Write(_controlCommandNames[(int)controlCmd.commandType]); + return; + } + + var nativeFunc = obj as Runtime.NativeFunctionCall; + if (nativeFunc) + { + var name = nativeFunc.name; + + // Avoid collision with ^ used to indicate a string + if (name == "^") name = "L^"; + + writer.Write(name); + return; + } + + + // Variable reference + var varRef = obj as VariableReference; + if (varRef) + { + writer.WriteObjectStart(); + + string readCountPath = varRef.pathStringForCount; + if (readCountPath != null) + { + writer.WriteProperty("CNT?", readCountPath); + } + else + { + writer.WriteProperty("VAR?", varRef.name); + } + + writer.WriteObjectEnd(); + return; + } + + // Variable assignment + var varAss = obj as VariableAssignment; + if (varAss) + { + writer.WriteObjectStart(); + + string key = varAss.isGlobal ? "VAR=" : "temp="; + writer.WriteProperty(key, varAss.variableName); + + // Reassignment? + if (!varAss.isNewDeclaration) + writer.WriteProperty("re", true); + + writer.WriteObjectEnd(); + + return; + } + + // Void + var voidObj = obj as Void; + if (voidObj) { + writer.Write("void"); + return; + } + + // Tag + var tag = obj as Tag; + if (tag) + { + writer.WriteObjectStart(); + writer.WriteProperty("#", tag.text); + writer.WriteObjectEnd(); + return; + } + + // Used when serialising save state only + var choice = obj as Choice; + if (choice) { + WriteChoice(writer, choice); + return; + } + + throw new System.Exception("Failed to write runtime object to JSON: " + obj); } public static Dictionary JObjectToDictionaryRuntimeObjs(Dictionary jObject) @@ -70,15 +276,6 @@ public static Dictionary JObjectToIntDictionary(Dictionary IntDictionaryToJObject(Dictionary dict) - { - var jObj = new Dictionary (); - foreach (var keyVal in dict) { - jObj [keyVal.Key] = keyVal.Value; - } - return jObj; - } - // ---------------------- // JSON ENCODING SCHEME // ---------------------- @@ -142,12 +339,7 @@ public static Runtime.Object JTokenToRuntimeObject(object token) return new StringValue ("\n"); // Glue - if (str == "<>") - return new Runtime.Glue (GlueType.Bidirectional); - else if(str == "G<") - return new Runtime.Glue (GlueType.Left); - else if(str == "G>") - return new Runtime.Glue (GlueType.Right); + if (str == "<>") return new Runtime.Glue (); // Control commands (would looking up in a hash set be faster?) for (int i = 0; i < _controlCommandNames.Length; ++i) { @@ -315,167 +507,12 @@ public static Runtime.Object JTokenToRuntimeObject(object token) throw new System.Exception ("Failed to convert token to runtime object: " + token); } - public static object RuntimeObjectToJToken(Runtime.Object obj) + public static void WriteRuntimeContainer(SimpleJson.Writer writer, Container container, bool withoutName = false) { - var container = obj as Container; - if (container) { - return ContainerToJArray (container); - } + writer.WriteArrayStart(); - var divert = obj as Divert; - if (divert) { - string divTypeKey = "->"; - if (divert.isExternal) - divTypeKey = "x()"; - else if (divert.pushesToStack) { - if (divert.stackPushType == PushPopType.Function) - divTypeKey = "f()"; - else if (divert.stackPushType == PushPopType.Tunnel) - divTypeKey = "->t->"; - } - - string targetStr; - if (divert.hasVariableTarget) - targetStr = divert.variableDivertName; - else - targetStr = divert.targetPathString; - - var jObj = new Dictionary (); - jObj[divTypeKey] = targetStr; - - if (divert.hasVariableTarget) - jObj ["var"] = true; - - if (divert.isConditional) - jObj ["c"] = true; - - if (divert.externalArgs > 0) - jObj ["exArgs"] = divert.externalArgs; - - return jObj; - } - - var choicePoint = obj as ChoicePoint; - if (choicePoint) { - var jObj = new Dictionary (); - jObj ["*"] = choicePoint.pathStringOnChoice; - jObj ["flg"] = choicePoint.flags; - return jObj; - } - - var intVal = obj as IntValue; - if (intVal) - return intVal.value; - - var floatVal = obj as FloatValue; - if (floatVal) - return floatVal.value; - - var strVal = obj as StringValue; - if (strVal) { - if (strVal.isNewline) - return "\n"; - else - return "^" + strVal.value; - } - - var listVal = obj as ListValue; - if (listVal) { - return InkListToJObject (listVal); - } - - var divTargetVal = obj as DivertTargetValue; - if (divTargetVal) { - var divTargetJsonObj = new Dictionary (); - divTargetJsonObj ["^->"] = divTargetVal.value.componentsString; - return divTargetJsonObj; - } - - var varPtrVal = obj as VariablePointerValue; - if (varPtrVal) { - var varPtrJsonObj = new Dictionary (); - varPtrJsonObj ["^var"] = varPtrVal.value; - varPtrJsonObj ["ci"] = varPtrVal.contextIndex; - return varPtrJsonObj; - } - - var glue = obj as Runtime.Glue; - if (glue) { - if (glue.isBi) - return "<>"; - else if (glue.isLeft) - return "G<"; - else - return "G>"; - } - - var controlCmd = obj as ControlCommand; - if (controlCmd) { - return _controlCommandNames [(int)controlCmd.commandType]; - } - - var nativeFunc = obj as Runtime.NativeFunctionCall; - if (nativeFunc) { - var name = nativeFunc.name; - - // Avoid collision with ^ used to indicate a string - if (name == "^") name = "L^"; - return name; - } - - - // Variable reference - var varRef = obj as VariableReference; - if (varRef) { - var jObj = new Dictionary (); - string readCountPath = varRef.pathStringForCount; - if (readCountPath != null) { - jObj ["CNT?"] = readCountPath; - } else { - jObj ["VAR?"] = varRef.name; - } - - return jObj; - } - - // Variable assignment - var varAss = obj as VariableAssignment; - if (varAss) { - string key = varAss.isGlobal ? "VAR=" : "temp="; - var jObj = new Dictionary (); - jObj [key] = varAss.variableName; - - // Reassignment? - if (!varAss.isNewDeclaration) - jObj ["re"] = true; - - return jObj; - } - - // Void - var voidObj = obj as Void; - if (voidObj) - return "void"; - - // Tag - var tag = obj as Tag; - if (tag) { - var jObj = new Dictionary (); - jObj ["#"] = tag.text; - return jObj; - } - - // Used when serialising save state only - var choice = obj as Choice; - if (choice) - return ChoiceToJObject (choice); - - throw new System.Exception ("Failed to convert runtime object to Json token: " + obj); - } - - static List ContainerToJArray(Container container) - { - var jArray = ListToJArray (container.content); + foreach (var c in container.content) + WriteRuntimeObject(writer, c); // Container is always an array [...] // But the final element is always either: @@ -484,43 +521,35 @@ static List ContainerToJArray(Container container) // - null, if neither of the above var namedOnlyContent = container.namedOnlyContent; var countFlags = container.countFlags; - if (namedOnlyContent != null && namedOnlyContent.Count > 0 || countFlags > 0 || container.name != null) { - - Dictionary terminatingObj; - if (namedOnlyContent != null) { - terminatingObj = DictionaryRuntimeObjsToJObject (namedOnlyContent); - - // Strip redundant names from containers if necessary - foreach (var namedContentObj in terminatingObj) { - var subContainerJArray = namedContentObj.Value as List; - if (subContainerJArray != null) { - var attrJObj = subContainerJArray [subContainerJArray.Count - 1] as Dictionary; - if (attrJObj != null) { - attrJObj.Remove ("#n"); - if (attrJObj.Count == 0) - subContainerJArray [subContainerJArray.Count - 1] = null; - } - } - } + var hasNameProperty = container.name != null && !withoutName; - } else - terminatingObj = new Dictionary (); + bool hasTerminator = namedOnlyContent != null || countFlags > 0 || hasNameProperty; - if( countFlags > 0 ) - terminatingObj ["#f"] = countFlags; + if( hasTerminator ) + writer.WriteObjectStart(); - if( container.name != null ) - terminatingObj ["#n"] = container.name; + if ( namedOnlyContent != null ) { + foreach(var namedContent in namedOnlyContent) { + var name = namedContent.Key; + var namedContainer = namedContent.Value as Container; + writer.WritePropertyStart(name); + WriteRuntimeContainer(writer, namedContainer, withoutName:true); + writer.WritePropertyEnd(); + } + } - jArray.Add (terminatingObj); - } + if (countFlags > 0) + writer.WriteProperty("#f", countFlags); - // Add null terminator to indicate that there's no dictionary - else { - jArray.Add (null); - } + if (hasNameProperty) + writer.WriteProperty("#n", container.name); + + if (hasTerminator) + writer.WriteObjectEnd(); + else + writer.WriteNull(); - return jArray; + writer.WriteArrayEnd(); } static Container JArrayToContainer(List jArray) @@ -562,57 +591,63 @@ static Choice JObjectToChoice(Dictionary jObj) var choice = new Choice(); choice.text = jObj ["text"].ToString(); choice.index = (int)jObj ["index"]; - choice.originalChoicePath = jObj ["originalChoicePath"].ToString(); + choice.sourcePath = jObj ["originalChoicePath"].ToString(); choice.originalThreadIndex = (int)jObj ["originalThreadIndex"]; + choice.pathStringOnChoice = jObj ["targetPath"].ToString(); return choice; } - - static Dictionary ChoiceToJObject(Choice choice) + public static void WriteChoice(SimpleJson.Writer writer, Choice choice) { - var jObj = new Dictionary (); - jObj ["text"] = choice.text; - jObj ["index"] = choice.index; - jObj ["originalChoicePath"] = choice.originalChoicePath; - jObj ["originalThreadIndex"] = choice.originalThreadIndex; - return jObj; + writer.WriteObjectStart(); + writer.WriteProperty("text", choice.text); + writer.WriteProperty("index", choice.index); + writer.WriteProperty("originalChoicePath", choice.sourcePath); + writer.WriteProperty("originalThreadIndex", choice.originalThreadIndex); + writer.WriteProperty("targetPath", choice.pathStringOnChoice); + writer.WriteObjectEnd(); } - static Dictionary InkListToJObject (ListValue listVal) + static void WriteInkList(SimpleJson.Writer writer, ListValue listVal) { var rawList = listVal.value; - var dict = new Dictionary (); + writer.WriteObjectStart(); + + writer.WritePropertyStart("list"); - var content = new Dictionary (); + writer.WriteObjectStart(); - foreach (var itemAndValue in rawList) { + foreach (var itemAndValue in rawList) + { var item = itemAndValue.Key; - int val = itemAndValue.Value; - content [item.ToString ()] = val; - } + int itemVal = itemAndValue.Value; - dict ["list"] = content; + writer.WritePropertyNameStart(); + writer.WritePropertyNameInner(item.originName ?? "?"); + writer.WritePropertyNameInner("."); + writer.WritePropertyNameInner(item.itemName); + writer.WritePropertyNameEnd(); - if (rawList.Count == 0 && rawList.originNames != null && rawList.originNames.Count > 0) { - dict ["origins"] = rawList.originNames.Cast ().ToList (); + writer.Write(itemVal); + + writer.WritePropertyEnd(); } - return dict; - } + writer.WriteObjectEnd(); - public static Dictionary ListDefinitionsToJToken (ListDefinitionsOrigin origin) - { - var result = new Dictionary (); - foreach (ListDefinition def in origin.lists) { - var listDefJson = new Dictionary (); - foreach (var itemToVal in def.items) { - InkListItem item = itemToVal.Key; - int val = itemToVal.Value; - listDefJson [item.itemName] = (object)val; - } - result [def.name] = listDefJson; + writer.WritePropertyEnd(); + + if (rawList.Count == 0 && rawList.originNames != null && rawList.originNames.Count > 0) + { + writer.WritePropertyStart("origins"); + writer.WriteArrayStart(); + foreach (var name in rawList.originNames) + writer.Write(name); + writer.WriteArrayEnd(); + writer.WritePropertyEnd(); } - return result; + + writer.WriteObjectEnd(); } public static ListDefinitionsOrigin JTokenToListDefinitions (object obj) @@ -652,6 +687,7 @@ static Json() _controlCommandNames [(int)ControlCommand.CommandType.EndString] = "/str"; _controlCommandNames [(int)ControlCommand.CommandType.NoOp] = "nop"; _controlCommandNames [(int)ControlCommand.CommandType.ChoiceCount] = "choiceCnt"; + _controlCommandNames [(int)ControlCommand.CommandType.Turns] = "turn"; _controlCommandNames [(int)ControlCommand.CommandType.TurnsSince] = "turns"; _controlCommandNames [(int)ControlCommand.CommandType.ReadCount] = "readc"; _controlCommandNames [(int)ControlCommand.CommandType.Random] = "rnd"; @@ -663,6 +699,7 @@ static Json() _controlCommandNames [(int)ControlCommand.CommandType.End] = "end"; _controlCommandNames [(int)ControlCommand.CommandType.ListFromInt] = "listInt"; _controlCommandNames [(int)ControlCommand.CommandType.ListRange] = "range"; + _controlCommandNames [(int)ControlCommand.CommandType.ListRandom] = "lrnd"; for (int i = 0; i < (int)ControlCommand.CommandType.TOTAL_VALUES; ++i) { if (_controlCommandNames [i] == null) diff --git a/ink-engine-runtime/ListDefinition.cs b/ink-engine-runtime/ListDefinition.cs index cfda8b0..7f08619 100644 --- a/ink-engine-runtime/ListDefinition.cs +++ b/ink-engine-runtime/ListDefinition.cs @@ -59,18 +59,6 @@ public bool TryGetValueForItem (InkListItem item, out int intVal) return _itemNameToValues.TryGetValue (item.itemName, out intVal); } - public ListValue ListRange (int min, int max) - { - var rawList = new InkList (); - foreach (var nameAndValue in _itemNameToValues) { - if (nameAndValue.Value >= min && nameAndValue.Value <= max) { - var item = new InkListItem (name, nameAndValue.Key); - rawList [item] = nameAndValue.Value; - } - } - return new ListValue(rawList); - } - public ListDefinition (string name, Dictionary items) { _name = name; diff --git a/ink-engine-runtime/ListDefinitionsOrigin.cs b/ink-engine-runtime/ListDefinitionsOrigin.cs index 425625a..44e20c1 100644 --- a/ink-engine-runtime/ListDefinitionsOrigin.cs +++ b/ink-engine-runtime/ListDefinitionsOrigin.cs @@ -17,46 +17,37 @@ public List lists { public ListDefinitionsOrigin (List lists) { _lists = new Dictionary (); + _allUnambiguousListValueCache = new Dictionary(); + foreach (var list in lists) { _lists [list.name] = list; + + foreach(var itemWithValue in list.items) { + var item = itemWithValue.Key; + var val = itemWithValue.Value; + var listValue = new ListValue(item, val); + + // May be ambiguous, but compiler should've caught that, + // so we may be doing some replacement here, but that's okay. + _allUnambiguousListValueCache[item.itemName] = listValue; + _allUnambiguousListValueCache[item.fullName] = listValue; + } } } - public bool TryGetDefinition (string name, out ListDefinition def) + public bool TryListGetDefinition (string name, out ListDefinition def) { return _lists.TryGetValue (name, out def); } public ListValue FindSingleItemListWithName (string name) { - InkListItem item = InkListItem.Null; - ListDefinition list = null; - - // Name could be in the form itemName or listName.itemName - var nameParts = name.Split ('.'); - if (nameParts.Length == 2) { - item = new InkListItem (nameParts [0], nameParts [1]); - TryGetDefinition (item.originName, out list); - } else { - foreach (var namedList in _lists) { - var listWithItem = namedList.Value; - item = new InkListItem (namedList.Key, name); - if (listWithItem.ContainsItem (item)) { - list = listWithItem; - break; - } - } - } - - // Manager to get the list that contains the given item? - if (list != null) { - int itemValue = list.ValueForItem (item); - return new ListValue (item, itemValue); - } - - return null; + ListValue val = null; + _allUnambiguousListValueCache.TryGetValue(name, out val); + return val; } Dictionary _lists; + Dictionary _allUnambiguousListValueCache; } } diff --git a/ink-engine-runtime/NativeFunctionCall.cs b/ink-engine-runtime/NativeFunctionCall.cs index 697032f..02127c1 100644 --- a/ink-engine-runtime/NativeFunctionCall.cs +++ b/ink-engine-runtime/NativeFunctionCall.cs @@ -28,6 +28,12 @@ internal class NativeFunctionCall : Runtime.Object public const string Min = "MIN"; public const string Max = "MAX"; + public const string Pow = "POW"; + public const string Floor = "FLOOR"; + public const string Ceiling = "CEILING"; + public const string Int = "INT"; + public const string Float = "FLOAT"; + public const string Has = "?"; public const string Hasnt = "!?"; public const string Intersect = "^"; @@ -294,13 +300,20 @@ public NativeFunctionCall() { } // Only called internally to generate prototypes - NativeFunctionCall (string name, int numberOfParamters) + NativeFunctionCall (string name, int numberOfParameters) { _isPrototype = true; this.name = name; - this.numberOfParameters = numberOfParamters; + this.numberOfParameters = numberOfParameters; } - + + // For defining operations that do nothing to the specific type + // (but are still supported), such as floor/ceil on int and float + // cast on float. + static object Identity(T t) { + return t; + } + static void GenerateNativeFunctionsIfNecessary() { if (_nativeFunctions == null) { @@ -328,6 +341,13 @@ static void GenerateNativeFunctionsIfNecessary() AddIntBinaryOp(Max, (x, y) => Math.Max(x, y)); AddIntBinaryOp(Min, (x, y) => Math.Min(x, y)); + // Have to cast to float since you could do POW(2, -1) + AddIntBinaryOp (Pow, (x, y) => (float) Math.Pow(x, y)); + AddIntUnaryOp(Floor, Identity); + AddIntUnaryOp(Ceiling, Identity); + AddIntUnaryOp(Int, Identity); + AddIntUnaryOp (Float, x => (float)x); + // Float operations AddFloatBinaryOp(Add, (x, y) => x + y); AddFloatBinaryOp(Subtract, (x, y) => x - y); @@ -350,11 +370,18 @@ static void GenerateNativeFunctionsIfNecessary() AddFloatBinaryOp(Max, (x, y) => Math.Max(x, y)); AddFloatBinaryOp(Min, (x, y) => Math.Min(x, y)); + AddFloatBinaryOp (Pow, (x, y) => (float)Math.Pow(x, y)); + AddFloatUnaryOp(Floor, x => (float)Math.Floor(x)); + AddFloatUnaryOp(Ceiling, x => (float)Math.Ceiling(x)); + AddFloatUnaryOp(Int, x => (int)x); + AddFloatUnaryOp(Float, Identity); + // String operations AddStringBinaryOp(Add, (x, y) => x + y); // concat AddStringBinaryOp(Equal, (x, y) => x.Equals(y) ? (int)1 : (int)0); AddStringBinaryOp (NotEquals, (x, y) => !x.Equals (y) ? (int)1 : (int)0); AddStringBinaryOp (Has, (x, y) => x.Contains(y) ? (int)1 : (int)0); + AddStringBinaryOp (Hasnt, (x, y) => x.Contains(y) ? (int)0 : (int)1); // List operations AddListBinaryOp (Add, (x, y) => x.Union (y)); @@ -384,11 +411,15 @@ static void GenerateNativeFunctionsIfNecessary() AddListUnaryOp (Count, (x) => x.Count); AddListUnaryOp (ValueOfList, (x) => x.maxItem.Value); - // Special case: The only operation you can do on divert target values + // Special case: The only operations you can do on divert target values BinaryOp divertTargetsEqual = (Path d1, Path d2) => { return d1.Equals (d2) ? 1 : 0; }; + BinaryOp divertTargetsNotEqual = (Path d1, Path d2) => { + return d1.Equals (d2) ? 0 : 1; + }; AddOpToNativeFunc (Equal, 2, ValueType.DivertTarget, divertTargetsEqual); + AddOpToNativeFunc (NotEquals, 2, ValueType.DivertTarget, divertTargetsNotEqual); } } diff --git a/ink-engine-runtime/Object.cs b/ink-engine-runtime/Object.cs index 24855b0..334a43d 100644 --- a/ink-engine-runtime/Object.cs +++ b/ink-engine-runtime/Object.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; @@ -32,6 +32,12 @@ internal Runtime.DebugMetadata debugMetadata { } } + internal Runtime.DebugMetadata ownDebugMetadata { + get { + return _debugMetadata; + } + } + // TODO: Come up with some clever solution for not having // to have debug metadata on the object itself, perhaps // for serialisation purposes at least. @@ -45,15 +51,7 @@ internal Runtime.DebugMetadata debugMetadata { // Try to get a line number from debug metadata var root = this.rootContentContainer; if (root) { - Runtime.Object targetContent = null; - - // Sometimes paths can be "invalid" if they're externally defined - // in the game. TODO: Change ContentAtPath to return null, and - // only throw an exception in places that actually care! - try { - targetContent = root.ContentAtPath (path); - } catch { } - + Runtime.Object targetContent = root.ContentAtPath (path).obj; if (targetContent) { var dm = targetContent.debugMetadata; if (dm != null) { @@ -105,7 +103,7 @@ internal Path path } Path _path; - internal Runtime.Object ResolvePath(Path path) + internal SearchResult ResolvePath(Path path) { if (path.isRelative) { @@ -114,7 +112,7 @@ internal Runtime.Object ResolvePath(Path path) Debug.Assert (this.parent != null, "Can't resolve relative path because we don't have a parent"); nearestContainer = this.parent as Container; Debug.Assert (nearestContainer != null, "Expected parent to be a container"); - Debug.Assert (path.components [0].isParent); + Debug.Assert (path.GetComponent(0).isParent); path = path.tail; } @@ -132,12 +130,12 @@ internal Path ConvertPathToRelative(Path globalPath) var ownPath = this.path; - int minPathLength = Math.Min (globalPath.components.Count, ownPath.components.Count); + int minPathLength = Math.Min (globalPath.length, ownPath.length); int lastSharedPathCompIndex = -1; for (int i = 0; i < minPathLength; ++i) { - var ownComp = ownPath.components [i]; - var otherComp = globalPath.components [i]; + var ownComp = ownPath.GetComponent(i); + var otherComp = globalPath.GetComponent(i); if (ownComp.Equals (otherComp)) { lastSharedPathCompIndex = i; @@ -150,15 +148,15 @@ internal Path ConvertPathToRelative(Path globalPath) if (lastSharedPathCompIndex == -1) return globalPath; - int numUpwardsMoves = (ownPath.components.Count-1) - lastSharedPathCompIndex; + int numUpwardsMoves = (ownPath.length-1) - lastSharedPathCompIndex; var newPathComps = new List (); for(int up=0; up components { get; private set; } + public Component GetComponent(int index) + { + return _components[index]; + } public bool isRelative { get; private set; } @@ -85,8 +88,8 @@ public Component head { get { - if (components.Count > 0) { - return components.First (); + if (_components.Count > 0) { + return _components.First (); } else { return null; } @@ -97,8 +100,8 @@ public Path tail { get { - if (components.Count >= 2) { - List tailComps = components.GetRange (1, components.Count - 1); + if (_components.Count >= 2) { + List tailComps = _components.GetRange (1, _components.Count - 1); return new Path(tailComps); } @@ -109,23 +112,23 @@ public Path tail } } - public int length { get { return components.Count; } } + public int length { get { return _components.Count; } } public Component lastComponent { get { - if (components.Count > 0) { - return components.Last (); - } else { + var lastComponentIdx = _components.Count-1; + if( lastComponentIdx >= 0 ) + return _components[lastComponentIdx]; + else return null; - } } } public bool containsNamedComponent { get { - foreach(var comp in components) { + foreach(var comp in _components) { if( !comp.isIndex ) { return true; } @@ -136,18 +139,18 @@ public bool containsNamedComponent { public Path() { - components = new List (); + _components = new List (); } public Path(Component head, Path tail) : this() { - components.Add (head); - components.AddRange (tail.components); + _components.Add (head); + _components.AddRange (tail._components); } public Path(IEnumerable components, bool relative = false) : this() { - this.components.AddRange (components); + this._components.AddRange (components); this.isRelative = relative; } @@ -169,65 +172,74 @@ public Path PathByAppendingPath(Path pathToAppend) Path p = new Path (); int upwardMoves = 0; - for (int i = 0; i < pathToAppend.components.Count; ++i) { - if (pathToAppend.components [i].isParent) { + for (int i = 0; i < pathToAppend._components.Count; ++i) { + if (pathToAppend._components [i].isParent) { upwardMoves++; } else { break; } } - for (int i = 0; i < this.components.Count - upwardMoves; ++i) { - p.components.Add (this.components [i]); + for (int i = 0; i < this._components.Count - upwardMoves; ++i) { + p._components.Add (this._components [i]); } - for(int i=upwardMoves; i _components; } } diff --git a/ink-engine-runtime/Pointer.cs b/ink-engine-runtime/Pointer.cs new file mode 100644 index 0000000..08ccb40 --- /dev/null +++ b/ink-engine-runtime/Pointer.cs @@ -0,0 +1,70 @@ +using Ink.Runtime; + +namespace Ink.Runtime +{ + /// + /// Internal structure used to point to a particular / current point in the story. + /// Where Path is a set of components that make content fully addressable, this is + /// a reference to the current container, and the index of the current piece of + /// content within that container. This scheme makes it as fast and efficient as + /// possible to increment the pointer (move the story forwards) in a way that's as + /// native to the internal engine as possible. + /// + internal struct Pointer + { + public Container container; + public int index; + + public Pointer (Container container, int index) + { + this.container = container; + this.index = index; + } + + public Runtime.Object Resolve () + { + if (index < 0) return container; + if (container == null) return null; + if (container.content.Count == 0) return container; + if (index >= container.content.Count) return null; + return container.content [index]; + + } + + public bool isNull { + get { + return container == null; + } + } + + public Path path { + get { + if( isNull ) return null; + + if (index >= 0) + return container.path.PathByAppendingComponent (new Path.Component(index)); + else + return container.path; + } + } + + public override string ToString () + { + if (container == null) + return "Ink Pointer (null)"; + + return "Ink Pointer -> " + container.path.ToString () + " -- index " + index; + } + + public static Pointer StartOf (Container container) + { + return new Pointer { + container = container, + index = 0 + }; + } + + public static Pointer Null = new Pointer { container = null, index = -1 }; + + } +} \ No newline at end of file diff --git a/ink-engine-runtime/Profiler.cs b/ink-engine-runtime/Profiler.cs new file mode 100644 index 0000000..09ab51f --- /dev/null +++ b/ink-engine-runtime/Profiler.cs @@ -0,0 +1,394 @@ +using System; +using System.Diagnostics; +using System.Collections.Generic; +using System.Text; +using System.Linq; + +namespace Ink.Runtime +{ + /// + /// Simple ink profiler that logs every instruction in the story and counts frequency and timing. + /// To use: + /// + /// var profiler = story.StartProfiling(), + /// + /// (play your story for a bit) + /// + /// var reportStr = profiler.Report(); + /// + /// story.EndProfiling(); + /// + /// + public class Profiler + { + /// + /// The root node in the hierarchical tree of recorded ink timings. + /// + public ProfileNode rootNode { + get { + return _rootNode; + } + } + + internal Profiler() { + _rootNode = new ProfileNode(); + } + + /// + /// Generate a printable report based on the data recording during profiling. + /// + public string Report() { + var sb = new StringBuilder(); + sb.AppendFormat("{0} CONTINUES / LINES:\n", _numContinues); + sb.AppendFormat("TOTAL TIME: {0}\n", FormatMillisecs(_continueTotal)); + sb.AppendFormat("SNAPSHOTTING: {0}\n", FormatMillisecs(_snapTotal)); + sb.AppendFormat("OTHER: {0}\n", FormatMillisecs(_continueTotal - (_stepTotal + _snapTotal))); + sb.Append(_rootNode.ToString()); + return sb.ToString(); + } + + internal void PreContinue() { + _continueWatch.Reset(); + _continueWatch.Start(); + } + + internal void PostContinue() { + _continueWatch.Stop(); + _continueTotal += Millisecs(_continueWatch); + _numContinues++; + } + + internal void PreStep() { + _currStepStack = null; + _stepWatch.Reset(); + _stepWatch.Start(); + } + + internal void Step(CallStack callstack) + { + _stepWatch.Stop(); + + var stack = new string[callstack.elements.Count]; + for(int i=0; i + /// Generate a printable report specifying the average and maximum times spent + /// stepping over different internal ink instruction types. + /// This report type is primarily used to profile the ink engine itself rather + /// than your own specific ink. + /// + public string StepLengthReport() + { + var sb = new StringBuilder(); + + sb.AppendLine("TOTAL: "+_rootNode.totalMillisecs+"ms"); + + var averageStepTimes = _stepDetails + .GroupBy(s => s.type) + .Select(typeToDetails => new KeyValuePair(typeToDetails.Key, typeToDetails.Average(d => d.time))) + .OrderByDescending(stepTypeToAverage => stepTypeToAverage.Value) + .Select(stepTypeToAverage => { + var typeName = stepTypeToAverage.Key; + var time = stepTypeToAverage.Value; + return typeName + ": " + time + "ms"; + }) + .ToArray(); + + sb.AppendLine("AVERAGE STEP TIMES: "+string.Join(", ", averageStepTimes)); + + var accumStepTimes = _stepDetails + .GroupBy(s => s.type) + .Select(typeToDetails => new KeyValuePair(typeToDetails.Key + " (x"+typeToDetails.Count()+")", typeToDetails.Sum(d => d.time))) + .OrderByDescending(stepTypeToAccum => stepTypeToAccum.Value) + .Select(stepTypeToAccum => { + var typeName = stepTypeToAccum.Key; + var time = stepTypeToAccum.Value; + return typeName + ": " + time; + }) + .ToArray(); + + sb.AppendLine("ACCUMULATED STEP TIMES: "+string.Join(", ", accumStepTimes)); + + return sb.ToString(); + } + + /// + /// Create a large log of all the internal instructions that were evaluated while profiling was active. + /// Log is in a tab-separated format, for easy loading into a spreadsheet application. + /// + public string Megalog() + { + var sb = new StringBuilder(); + + sb.AppendLine("Step type\tDescription\tPath\tTime"); + + foreach(var step in _stepDetails) { + sb.Append(step.type); + sb.Append("\t"); + sb.Append(step.obj.ToString()); + sb.Append("\t"); + sb.Append(step.obj.path); + sb.Append("\t"); + sb.AppendLine(step.time.ToString("F8")); + } + + return sb.ToString(); + } + + internal void PreSnapshot() { + _snapWatch.Reset(); + _snapWatch.Start(); + } + + internal void PostSnapshot() { + _snapWatch.Stop(); + _snapTotal += Millisecs(_snapWatch); + } + + double Millisecs(Stopwatch watch) + { + var ticks = watch.ElapsedTicks; + return ticks * _millisecsPerTick; + } + + internal static string FormatMillisecs(double num) { + if( num > 5000 ) { + return string.Format("{0:N1} secs", num / 1000.0); + } if( num > 1000 ) { + return string.Format("{0:N2} secs", num / 1000.0); + } else if( num > 100 ) { + return string.Format("{0:N0} ms", num); + } else if( num > 1 ) { + return string.Format("{0:N1} ms", num); + } else if( num > 0.01 ) { + return string.Format("{0:N3} ms", num); + } else { + return string.Format("{0:N} ms", num); + } + } + + Stopwatch _continueWatch = new Stopwatch(); + Stopwatch _stepWatch = new Stopwatch(); + Stopwatch _snapWatch = new Stopwatch(); + + double _continueTotal; + double _snapTotal; + double _stepTotal; + + string[] _currStepStack; + StepDetails _currStepDetails; + ProfileNode _rootNode; + int _numContinues; + + struct StepDetails { + public string type; + public Runtime.Object obj; + public double time; + } + List _stepDetails = new List(); + + static double _millisecsPerTick = 1000.0 / Stopwatch.Frequency; + } + + + /// + /// Node used in the hierarchical tree of timings used by the Profiler. + /// Each node corresponds to a single line viewable in a UI-based representation. + /// + public class ProfileNode { + + /// + /// The key for the node corresponds to the printable name of the callstack element. + /// + public readonly string key; + + + #pragma warning disable 0649 + /// + /// Horribly hacky field only used by ink unity integration, + /// but saves constructing an entire data structure that mirrors + /// the one in here purely to store the state of whether each + /// node in the UI has been opened or not /// + public bool openInUI; + #pragma warning restore 0649 + + /// + /// Whether this node contains any sub-nodes - i.e. does it call anything else + /// that has been recorded? + /// + /// true if has children; otherwise, false. + public bool hasChildren { + get { + return _nodes != null && _nodes.Count > 0; + } + } + + /// + /// Total number of milliseconds this node has been active for. + /// + public int totalMillisecs { + get { + return (int)_totalMillisecs; + } + } + + internal ProfileNode() { + + } + + internal ProfileNode(string key) { + this.key = key; + } + + internal void AddSample(string[] stack, double duration) { + AddSample(stack, -1, duration); + } + + void AddSample(string[] stack, int stackIdx, double duration) { + + _totalSampleCount++; + _totalMillisecs += duration; + + if( stackIdx == stack.Length-1 ) { + _selfSampleCount++; + _selfMillisecs += duration; + } + + if( stackIdx+1 < stack.Length ) + AddSampleToNode(stack, stackIdx+1, duration); + } + + void AddSampleToNode(string[] stack, int stackIdx, double duration) + { + var nodeKey = stack[stackIdx]; + if( _nodes == null ) _nodes = new Dictionary(); + + ProfileNode node; + if( !_nodes.TryGetValue(nodeKey, out node) ) { + node = new ProfileNode(nodeKey); + _nodes[nodeKey] = node; + } + + node.AddSample(stack, stackIdx, duration); + } + + /// + /// Returns a sorted enumerable of the nodes in descending order of + /// how long they took to run. + /// + public IEnumerable> descendingOrderedNodes { + get { + if( _nodes == null ) return null; + return _nodes.OrderByDescending(keyNode => keyNode.Value._totalMillisecs); + } + } + + void PrintHierarchy(StringBuilder sb, int indent) + { + Pad(sb, indent); + + sb.Append(key); + sb.Append(": "); + sb.AppendLine(ownReport); + + if( _nodes == null ) return; + + foreach(var keyNode in descendingOrderedNodes) { + keyNode.Value.PrintHierarchy(sb, indent+1); + } + } + + /// + /// Generates a string giving timing information for this single node, including + /// total milliseconds spent on the piece of ink, the time spent within itself + /// (v.s. spent in children), as well as the number of samples (instruction steps) + /// recorded for both too. + /// + /// The own report. + public string ownReport { + get { + var sb = new StringBuilder(); + sb.Append("total "); + sb.Append(Profiler.FormatMillisecs(_totalMillisecs)); + sb.Append(", self "); + sb.Append(Profiler.FormatMillisecs(_selfMillisecs)); + sb.Append(" ("); + sb.Append(_selfSampleCount); + sb.Append(" self samples, "); + sb.Append(_totalSampleCount); + sb.Append(" total)"); + return sb.ToString(); + } + + } + + void Pad(StringBuilder sb, int spaces) + { + for(int i=0; i + /// String is a report of the sub-tree from this node, but without any of the header information + /// that's prepended by the Profiler in its Report() method. + /// + public override string ToString () + { + var sb = new StringBuilder(); + PrintHierarchy(sb, 0); + return sb.ToString(); + } + + Dictionary _nodes; + double _selfMillisecs; + double _totalMillisecs; + int _selfSampleCount; + int _totalSampleCount; + } +} + diff --git a/ink-engine-runtime/PushPop.cs b/ink-engine-runtime/PushPop.cs index 2ff62bf..fe6fe4d 100644 --- a/ink-engine-runtime/PushPop.cs +++ b/ink-engine-runtime/PushPop.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; namespace Ink.Runtime @@ -6,7 +6,8 @@ namespace Ink.Runtime internal enum PushPopType { Tunnel, - Function + Function, + FunctionEvaluationFromGame } } diff --git a/ink-engine-runtime/SearchResult.cs b/ink-engine-runtime/SearchResult.cs new file mode 100644 index 0000000..18a1390 --- /dev/null +++ b/ink-engine-runtime/SearchResult.cs @@ -0,0 +1,18 @@ +using System; +namespace Ink.Runtime +{ + // When looking up content within the story (e.g. in Container.ContentAtPath), + // the result is generally found, but if the story is modified, then when loading + // up an old save state, then some old paths may still exist. In this case we + // try to recover by finding an approximate result by working up the story hierarchy + // in the path to find the closest valid container. Instead of crashing horribly, + // we might see some slight oddness in the content, but hopefully it recovers! + internal struct SearchResult + { + public Runtime.Object obj; + public bool approximate; + + public Runtime.Object correctObj { get { return approximate ? null : obj; } } + public Container container { get { return obj as Container; } } + } +} diff --git a/ink-engine-runtime/SimpleJson.cs b/ink-engine-runtime/SimpleJson.cs index 950a9a6..f4a9899 100644 --- a/ink-engine-runtime/SimpleJson.cs +++ b/ink-engine-runtime/SimpleJson.cs @@ -1,6 +1,7 @@ using System; using System.Text; using System.Collections.Generic; +using System.IO; namespace Ink.Runtime { @@ -10,14 +11,14 @@ namespace Ink.Runtime /// internal static class SimpleJson { - public static string DictionaryToText (Dictionary rootObject) + public static Dictionary TextToDictionary (string text) { - return new Writer (rootObject).ToString (); + return new Reader (text).ToDictionary (); } - public static Dictionary TextToDictionary (string text) + public static List TextToArray(string text) { - return new Reader (text).ToDictionary (); + return new Reader(text).ToArray(); } class Reader @@ -37,9 +38,19 @@ public Dictionary ToDictionary () return (Dictionary)_rootObject; } + public List ToArray() + { + return (List)_rootObject; + } + bool IsNumberChar (char c) { - return c >= '0' && c <= '9' || c == '.' || c == '-' || c == '+'; + return c >= '0' && c <= '9' || c == '.' || c == '-' || c == '+' || c == 'E' || c == 'e'; + } + + bool IsFirstNumberChar(char c) + { + return c >= '0' && c <= '9' || c == '-' || c == '+'; } object ReadObject () @@ -55,7 +66,7 @@ object ReadObject () else if (currentChar == '"') return ReadString (); - else if (IsNumberChar(currentChar)) + else if (IsFirstNumberChar(currentChar)) return ReadNumber (); else if (TryRead ("true")) @@ -148,27 +159,63 @@ string ReadString () { Expect ("\""); - var startOffset = _offset; + var sb = new StringBuilder(); for (; _offset < _text.Length; _offset++) { var c = _text [_offset]; - // Escaping. Escaped character will be skipped over in next loop. if (c == '\\') { + // Escaped character _offset++; + if (_offset >= _text.Length) { + throw new Exception("Unexpected EOF while reading string"); + } + c = _text[_offset]; + switch (c) + { + case '"': + case '\\': + case '/': // Yes, JSON allows this to be escaped + sb.Append(c); + break; + case 'n': + sb.Append('\n'); + break; + case 't': + sb.Append('\t'); + break; + case 'r': + case 'b': + case 'f': + // Ignore other control characters + break; + case 'u': + // 4-digit Unicode + if (_offset + 4 >=_text.Length) { + throw new Exception("Unexpected EOF while reading string"); + } + var digits = _text.Substring(_offset + 1, 4); + int uchar; + if (int.TryParse(digits, System.Globalization.NumberStyles.AllowHexSpecifier, System.Globalization.CultureInfo.InvariantCulture, out uchar)) { + sb.Append((char)uchar); + _offset += 4; + } else { + throw new Exception("Invalid Unicode escape character at offset " + (_offset - 1)); + } + break; + default: + // The escaped character is invalid per json spec + throw new Exception("Invalid Unicode escape character at offset " + (_offset - 1)); + } } else if( c == '"' ) { break; + } else { + sb.Append(c); } } Expect ("\""); - - var str = _text.Substring (startOffset, _offset - startOffset - 1); - str = str.Replace ("\\\\", "\\"); - str = str.Replace ("\\\"", "\""); - str = str.Replace ("\\r", ""); - str = str.Replace ("\\n", "\n"); - return str; + return sb.ToString(); } object ReadNumber () @@ -178,7 +225,7 @@ object ReadNumber () bool isFloat = false; for (; _offset < _text.Length; _offset++) { var c = _text [_offset]; - if (c == '.') isFloat = true; + if (c == '.' || c == 'e' || c == 'E') isFloat = true; if (IsNumberChar (c)) continue; else @@ -199,7 +246,7 @@ object ReadNumber () } } - throw new System.Exception ("Failed to parse number value"); + throw new System.Exception ("Failed to parse number value: "+numStr); } bool TryRead (string textToRead) @@ -254,91 +301,347 @@ void SkipWhitespace () object _rootObject; } - class Writer + + public class Writer { - public Writer (object rootObject) - { - _sb = new StringBuilder (); - - WriteObject (rootObject); - } - - void WriteObject (object obj) - { - if (obj is int) { - _sb.Append ((int)obj); - } else if (obj is float) { - string floatStr = ((float)obj).ToString(System.Globalization.CultureInfo.InvariantCulture); - _sb.Append (floatStr); - if (!floatStr.Contains (".")) _sb.Append (".0"); - } else if( obj is bool) { - _sb.Append ((bool)obj == true ? "true" : "false"); - } else if (obj == null) { - _sb.Append ("null"); - } else if (obj is string) { - string str = (string)obj; - - // Escape backslashes, quotes and newlines - str = str.Replace ("\\", "\\\\"); - str = str.Replace ("\"", "\\\""); - str = str.Replace ("\n", "\\n"); - str = str.Replace ("\r", ""); - - _sb.AppendFormat ("\"{0}\"", str); - } else if (obj is Dictionary) { - WriteDictionary ((Dictionary)obj); - } else if (obj is List) { - WriteList ((List)obj); - }else { - throw new System.Exception ("ink's SimpleJson writer doesn't currently support this object: " + obj); - } + public Writer() + { + _writer = new StringWriter(); + } + + public Writer(Stream stream) + { + _writer = new System.IO.StreamWriter(stream, Encoding.UTF8); + } + + public void WriteObject(Action inner) + { + WriteObjectStart(); + inner(this); + WriteObjectEnd(); + } + + public void WriteObjectStart() + { + StartNewObject(container: true); + _stateStack.Push(new StateElement { type = State.Object }); + _writer.Write("{"); + } + + public void WriteObjectEnd() + { + Assert(state == State.Object); + _writer.Write("}"); + _stateStack.Pop(); + } + + public void WriteProperty(string name, Action inner) + { + WriteProperty(name, inner); + } + + public void WriteProperty(int id, Action inner) + { + WriteProperty(id, inner); + } + + public void WriteProperty(string name, string content) + { + WritePropertyStart(name); + Write(content); + WritePropertyEnd(); + } + + public void WriteProperty(string name, int content) + { + WritePropertyStart(name); + Write(content); + WritePropertyEnd(); + } + + public void WriteProperty(string name, bool content) + { + WritePropertyStart(name); + Write(content); + WritePropertyEnd(); + } + + public void WritePropertyStart(string name) + { + WritePropertyStart(name); + } + + public void WritePropertyStart(int id) + { + WritePropertyStart(id); + } + + public void WritePropertyEnd() + { + Assert(state == State.Property); + Assert(childCount == 1); + _stateStack.Pop(); + } + + public void WritePropertyNameStart() + { + Assert(state == State.Object); + + if (childCount > 0) + _writer.Write(","); + + _writer.Write("\""); + + IncrementChildCount(); + + _stateStack.Push(new StateElement { type = State.Property }); + _stateStack.Push(new StateElement { type = State.PropertyName }); } - void WriteDictionary (Dictionary dict) + public void WritePropertyNameEnd() { - _sb.Append ("{"); + Assert(state == State.PropertyName); + + _writer.Write("\":"); - bool isFirst = true; - foreach (var keyValue in dict) { + // Pop PropertyName, leaving Property state + _stateStack.Pop(); + } - if (!isFirst) _sb.Append (","); + public void WritePropertyNameInner(string str) + { + Assert(state == State.PropertyName); + _writer.Write(str); + } - _sb.Append ("\""); - _sb.Append (keyValue.Key); - _sb.Append ("\":"); + void WritePropertyStart(T name) + { + Assert(state == State.Object); - WriteObject (keyValue.Value); + if (childCount > 0) + _writer.Write(","); - isFirst = false; + _writer.Write("\""); + _writer.Write(name); + _writer.Write("\":"); + + IncrementChildCount(); + + _stateStack.Push(new StateElement { type = State.Property }); + } + + + // allow name to be string or int + void WriteProperty(T name, Action inner) + { + WritePropertyStart(name); + + inner(this); + + WritePropertyEnd(); + } + + public void WriteArrayStart() + { + StartNewObject(container: true); + _stateStack.Push(new StateElement { type = State.Array }); + _writer.Write("["); + } + + public void WriteArrayEnd() + { + Assert(state == State.Array); + _writer.Write("]"); + _stateStack.Pop(); + } + + public void Write(int i) + { + StartNewObject(container: false); + _writer.Write(i); + } + + public void Write(float f) + { + StartNewObject(container: false); + + // TODO: Find an heap-allocation-free way to do this please! + // _writer.Write(formatStr, obj (the float)) requires boxing + // Following implementation seems to work ok but requires creating temporary garbage string. + string floatStr = f.ToString(System.Globalization.CultureInfo.InvariantCulture); + if( floatStr == "Infinity" ) { + _writer.Write("3.4E+38"); // JSON doesn't support, do our best alternative + } else if (floatStr == "-Infinity") { + _writer.Write("-3.4E+38"); // JSON doesn't support, do our best alternative + } else if ( floatStr == "NaN" ) { + _writer.Write("0.0"); // JSON doesn't support, not much we can do + } else { + _writer.Write(floatStr); + if (!floatStr.Contains(".") && !floatStr.Contains("E")) + _writer.Write(".0"); // ensure it gets read back in as a floating point value } + } + + public void Write(string str, bool escape = true) + { + StartNewObject(container: false); + + _writer.Write("\""); + if (escape) + WriteEscapedString(str); + else + _writer.Write(str); + _writer.Write("\""); + } - _sb.Append ("}"); + public void Write(bool b) + { + StartNewObject(container: false); + _writer.Write(b ? "true" : "false"); } - void WriteList (List list) + public void WriteNull() { - _sb.Append ("["); + StartNewObject(container: false); + _writer.Write("null"); + } - bool isFirst = true; - foreach (var obj in list) { - if (!isFirst) _sb.Append (","); + public void WriteStringStart() + { + StartNewObject(container: false); + _stateStack.Push(new StateElement { type = State.String }); + _writer.Write("\""); + } - WriteObject (obj); + public void WriteStringEnd() + { + Assert(state == State.String); + _writer.Write("\""); + _stateStack.Pop(); + } - isFirst = false; + public void WriteStringInner(string str, bool escape = true) + { + Assert(state == State.String); + if (escape) + WriteEscapedString(str); + else + _writer.Write(str); + } + + void WriteEscapedString(string str) + { + foreach (var c in str) + { + if (c < ' ') + { + // Don't write any control characters except \n and \t + switch (c) + { + case '\n': + _writer.Write("\\n"); + break; + case '\t': + _writer.Write("\\t"); + break; + } + } + else + { + switch (c) + { + case '\\': + case '"': + _writer.Write("\\"); + _writer.Write(c); + break; + default: + _writer.Write(c); + break; + } + } } + } + + void StartNewObject(bool container) + { + + if (container) + Assert(state == State.None || state == State.Property || state == State.Array); + else + Assert(state == State.Property || state == State.Array); + + if (state == State.Array && childCount > 0) + _writer.Write(","); + + if (state == State.Property) + Assert(childCount == 0); - _sb.Append ("]"); + if (state == State.Array || state == State.Property) + IncrementChildCount(); } - public override string ToString () + State state { - return _sb.ToString (); + get + { + if (_stateStack.Count > 0) return _stateStack.Peek().type; + else return State.None; + } } + int childCount + { + get + { + if (_stateStack.Count > 0) return _stateStack.Peek().childCount; + else return 0; + } + } - StringBuilder _sb; + void IncrementChildCount() + { + Assert(_stateStack.Count > 0); + var currEl = _stateStack.Pop(); + currEl.childCount++; + _stateStack.Push(currEl); + } + + // Shouldn't hit this assert outside of initial JSON development, + // so it's save to make it debug-only. + [System.Diagnostics.Conditional("DEBUG")] + void Assert(bool condition) + { + if (!condition) + throw new System.Exception("Assert failed while writing JSON"); + } + + public override string ToString() + { + return _writer.ToString(); + } + + enum State + { + None, + Object, + Array, + Property, + PropertyName, + String + }; + + struct StateElement + { + public State type; + public int childCount; + } + + Stack _stateStack = new Stack(); + TextWriter _writer; } + + } } diff --git a/ink-engine-runtime/StatePatch.cs b/ink-engine-runtime/StatePatch.cs new file mode 100644 index 0000000..1662cfe --- /dev/null +++ b/ink-engine-runtime/StatePatch.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; + +namespace Ink.Runtime +{ + internal class StatePatch + { + public Dictionary globals { get { return _globals; } } + public HashSet changedVariables { get { return _changedVariables; } } + public Dictionary visitCounts { get { return _visitCounts; } } + public Dictionary turnIndices { get { return _turnIndices; } } + + public StatePatch(StatePatch toCopy) + { + if( toCopy != null ) { + _globals = new Dictionary(toCopy._globals); + _changedVariables = new HashSet(toCopy._changedVariables); + _visitCounts = new Dictionary(toCopy._visitCounts); + _turnIndices = new Dictionary(toCopy._turnIndices); + } else { + _globals = new Dictionary(); + _changedVariables = new HashSet(); + _visitCounts = new Dictionary(); + _turnIndices = new Dictionary(); + } + } + + public bool TryGetGlobal(string name, out Runtime.Object value) + { + return _globals.TryGetValue(name, out value); + } + + public void SetGlobal(string name, Runtime.Object value){ + _globals[name] = value; + } + + public void AddChangedVariable(string name) + { + _changedVariables.Add(name); + } + + public bool TryGetVisitCount(Container container, out int count) + { + return _visitCounts.TryGetValue(container, out count); + } + + public void SetVisitCount(Container container, int count) + { + _visitCounts[container] = count; + } + + public void SetTurnIndex(Container container, int index) + { + _turnIndices[container] = index; + } + + public bool TryGetTurnIndex(Container container, out int index) + { + return _turnIndices.TryGetValue(container, out index); + } + + Dictionary _globals; + HashSet _changedVariables = new HashSet(); + Dictionary _visitCounts = new Dictionary(); + Dictionary _turnIndices = new Dictionary(); + } +} diff --git a/ink-engine-runtime/Story.cs b/ink-engine-runtime/Story.cs index 39e27da..af1a689 100644 --- a/ink-engine-runtime/Story.cs +++ b/ink-engine-runtime/Story.cs @@ -3,1528 +3,2023 @@ using System.Linq; using System.Text; using System.IO; +using System.Diagnostics; namespace Ink.Runtime { - /// - /// A Story is the core class that represents a complete Ink narrative, and - /// manages the evaluation and state of it. - /// + /// + /// A Story is the core class that represents a complete Ink narrative, and + /// manages the evaluation and state of it. + /// public class Story : Runtime.Object { - /// - /// The current version of the ink story file format. - /// - public const int inkVersionCurrent = 17; - - // Version numbers are for engine itself and story file, rather - // than the story state save format (which is um, currently nonexistant) - // -- old engine, new format: always fail - // -- new engine, old format: possibly cope, based on this number - // When incrementing the version number above, the question you - // should ask yourself is: - // -- Will the engine be able to load an old story file from - // before I made these changes to the engine? - // If possible, you should support it, though it's not as - // critical as loading old save games, since it's an - // in-development problem only. - - /// - /// The minimum legacy version of ink that can be loaded by the current version of the code. - /// - const int inkVersionMinimumCompatible = 16; - - /// - /// The list of Choice objects available at the current point in - /// the Story. This list will be populated as the Story is stepped - /// through with the Continue() method. Once canContinue becomes - /// false, this list will be populated, and is usually - /// (but not always) on the final Continue() step. - /// - public List currentChoices - { - get + /// + /// The current version of the ink story file format. + /// + public const int inkVersionCurrent = 19; + + // Version numbers are for engine itself and story file, rather + // than the story state save format + // -- old engine, new format: always fail + // -- new engine, old format: possibly cope, based on this number + // When incrementing the version number above, the question you + // should ask yourself is: + // -- Will the engine be able to load an old story file from + // before I made these changes to the engine? + // If possible, you should support it, though it's not as + // critical as loading old save games, since it's an + // in-development problem only. + + /// + /// The minimum legacy version of ink that can be loaded by the current version of the code. + /// + const int inkVersionMinimumCompatible = 18; + + /// + /// The list of Choice objects available at the current point in + /// the Story. This list will be populated as the Story is stepped + /// through with the Continue() method. Once canContinue becomes + /// false, this list will be populated, and is usually + /// (but not always) on the final Continue() step. + /// + public List currentChoices + { + get { - // Don't include invisible choices for external usage. - var choices = new List(); - foreach (var c in _state.currentChoices) { - if (!c.choicePoint.isInvisibleDefault) { - c.index = choices.Count; - choices.Add (c); - } - } - return choices; - } - } - - /// - /// The latest line of text to be generated from a Continue() call. - /// - public string currentText { get { return state.currentText; } } - - /// - /// Gets a list of tags as defined with '#' in source that were seen - /// during the latest Continue() call. - /// - public List currentTags { get { return state.currentTags; } } - - /// - /// Any errors generated during evaluation of the Story. - /// - public List currentErrors { get { return state.currentErrors; } } - - /// - /// Whether the currentErrors list contains any errors. - /// - public bool hasError { get { return state.hasError; } } - - /// - /// The VariablesState object contains all the global variables in the story. - /// However, note that there's more to the state of a Story than just the - /// global variables. This is a convenience accessor to the full state object. - /// - public VariablesState variablesState{ get { return state.variablesState; } } - - internal ListDefinitionsOrigin listDefinitions { - get { - return _listDefinitions; - } - } - - /// - /// The entire current state of the story including (but not limited to): - /// - /// * Global variables - /// * Temporary variables - /// * Read/visit and turn counts - /// * The callstack and evaluation stacks - /// * The current threads - /// - /// - public StoryState state { get { return _state; } } - - // Warning: When creating a Story using this constructor, you need to - // call ResetState on it before use. Intended for compiler use only. - // For normal use, use the constructor that takes a json string. - internal Story (Container contentContainer, List lists = null) + // Don't include invisible choices for external usage. + var choices = new List(); + foreach (var c in _state.currentChoices) { + if (!c.isInvisibleDefault) { + c.index = choices.Count; + choices.Add (c); + } + } + return choices; + } + } + + /// + /// The latest line of text to be generated from a Continue() call. + /// + public string currentText { + get { + IfAsyncWeCant ("call currentText since it's a work in progress"); + return state.currentText; + } + } + + /// + /// Gets a list of tags as defined with '#' in source that were seen + /// during the latest Continue() call. + /// + public List currentTags { + get { + IfAsyncWeCant ("call currentTags since it's a work in progress"); + return state.currentTags; + } + } + + /// + /// Any errors generated during evaluation of the Story. + /// + public List currentErrors { get { return state.currentErrors; } } + + /// + /// Any warnings generated during evaluation of the Story. + /// + public List currentWarnings { get { return state.currentWarnings; } } + + /// + /// Whether the currentErrors list contains any errors. + /// + public bool hasError { get { return state.hasError; } } + + /// + /// Whether the currentWarnings list contains any warnings. + /// + public bool hasWarning { get { return state.hasWarning; } } + + /// + /// The VariablesState object contains all the global variables in the story. + /// However, note that there's more to the state of a Story than just the + /// global variables. This is a convenience accessor to the full state object. + /// + public VariablesState variablesState{ get { return state.variablesState; } } + + internal ListDefinitionsOrigin listDefinitions { + get { + return _listDefinitions; + } + } + + /// + /// The entire current state of the story including (but not limited to): + /// + /// * Global variables + /// * Temporary variables + /// * Read/visit and turn counts + /// * The callstack and evaluation stacks + /// * The current threads + /// + /// + public StoryState state { get { return _state; } } + + + /// + /// Callback for when ContinueInternal is complete + /// + public event Action onDidContinue; + /// + /// Callback for when a choice is about to be executed + /// + public event Action onMakeChoice; + /// + /// Callback for when a function is about to be evaluated + /// + public event Action onEvaluateFunction; + /// + /// Callback for when a function has been evaluated + /// This is necessary because evaluating a function can cause continuing + /// + public event Action onCompleteEvaluateFunction; + /// + /// Callback for when a path string is chosen + /// + public event Action onChoosePathString; + + /// + /// Start recording ink profiling information during calls to Continue on Story. + /// Return a Profiler instance that you can request a report from when you're finished. + /// + public Profiler StartProfiling() { + IfAsyncWeCant ("start profiling"); + _profiler = new Profiler(); + return _profiler; + } + + /// + /// Stop recording ink profiling information during calls to Continue on Story. + /// To generate a report from the profiler, call + /// + public void EndProfiling() { + _profiler = null; + } + + // Warning: When creating a Story using this constructor, you need to + // call ResetState on it before use. Intended for compiler use only. + // For normal use, use the constructor that takes a json string. + internal Story (Container contentContainer, List lists = null) { _mainContentContainer = contentContainer; - if (lists != null) - _listDefinitions = new ListDefinitionsOrigin (lists); - - _externals = new Dictionary (); - } - - /// - /// Construct a Story object using a JSON string compiled through inklecate. - /// - public Story(string jsonString) : this((Container)null) - { - Dictionary rootObject = SimpleJson.TextToDictionary (jsonString); - - object versionObj = rootObject ["inkVersion"]; - if (versionObj == null) - throw new System.Exception ("ink version number not found. Are you sure it's a valid .ink.json file?"); - - int formatFromFile = (int)versionObj; - if (formatFromFile > inkVersionCurrent) { - throw new System.Exception ("Version of ink used to build story was newer than the current verison of the engine"); - } else if (formatFromFile < inkVersionMinimumCompatible) { - throw new System.Exception ("Version of ink used to build story is too old to be loaded by this verison of the engine"); - } else if (formatFromFile != inkVersionCurrent) { - System.Diagnostics.Debug.WriteLine ("WARNING: Version of ink used to build story doesn't match current version of engine. Non-critical, but recommend synchronising."); - } - - var rootToken = rootObject ["root"]; - if (rootToken == null) - throw new System.Exception ("Root node for ink not found. Are you sure it's a valid .ink.json file?"); - - object listDefsObj; - if (rootObject.TryGetValue ("listDefs", out listDefsObj)) { - _listDefinitions = Json.JTokenToListDefinitions (listDefsObj); - } - - _mainContentContainer = Json.JTokenToRuntimeObject (rootToken) as Container; - - ResetState (); - } - - /// - /// The Story itself in JSON representation. - /// - public string ToJsonString() - { - var rootContainerJsonList = (List) Json.RuntimeObjectToJToken (_mainContentContainer); - - var rootObject = new Dictionary (); - rootObject ["inkVersion"] = inkVersionCurrent; - rootObject ["root"] = rootContainerJsonList; - - if (_listDefinitions != null) - rootObject ["listDefs"] = Json.ListDefinitionsToJToken (_listDefinitions); - - return SimpleJson.DictionaryToText (rootObject); - } - - /// - /// Reset the Story back to its initial state as it was when it was - /// first constructed. - /// - public void ResetState() - { - _state = new StoryState (this); - _state.variablesState.variableChangedEvent += VariableStateDidChangeEvent; - - ResetGlobals (); - } - - /// - /// Reset the runtime error list within the state. - /// - public void ResetErrors() - { - _state.ResetErrors (); - } - - /// - /// Unwinds the callstack. Useful to reset the Story's evaluation - /// without actually changing any meaningful state, for example if - /// you want to exit a section of story prematurely and tell it to - /// go elsewhere with a call to ChoosePathString(...). - /// Doing so without calling ResetCallstack() could cause unexpected - /// issues if, for example, the Story was in a tunnel already. - /// - public void ResetCallstack() - { - _state.ForceEnd (); - } - - void ResetGlobals() - { - if (_mainContentContainer.namedContent.ContainsKey ("global decl")) { - var originalPath = state.currentPath; - - ChoosePathString ("global decl"); - - // Continue, but without validating external bindings, - // since we may be doing this reset at initialisation time. - ContinueInternal (); - - state.currentPath = originalPath; - } - } - - /// - /// Continue the story for one line of content, if possible. - /// If you're not sure if there's more content available, for example if you - /// want to check whether you're at a choice point or at the end of the story, - /// you should call canContinue before calling this function. - /// - /// The line of text content. - public string Continue() - { - // TODO: Should we leave this to the client, since it could be - // slow to iterate through all the content an extra time? - if( !_hasValidatedExternals ) - ValidateExternalBindings (); - - - return ContinueInternal (); - } - - - string ContinueInternal() - { - if (!canContinue) { - throw new StoryException ("Can't continue - should check canContinue before calling Continue"); - } - - _state.ResetOutput (); - - _state.didSafeExit = false; - - _state.variablesState.batchObservingVariableChanges = true; - - //_previousContainer = null; - - try { - - StoryState stateAtLastNewline = null; - - // The basic algorithm here is: - // - // do { Step() } while( canContinue && !outputStreamEndsInNewline ); - // - // But the complexity comes from: - // - Stepping beyond the newline in case it'll be absorbed by glue later - // - Ensuring that non-text content beyond newlines are generated - i.e. choices, - // which are actually built out of text content. - // So we have to take a snapshot of the state, continue prospectively, - // and rewind if necessary. - // This code is slightly fragile :-/ - // - - do { - - // Run main step function (walks through content) - Step(); - - // Run out of content and we have a default invisible choice that we can follow? - if( !canContinue ) { - TryFollowDefaultInvisibleChoice(); - } - - // Don't save/rewind during string evaluation, which is e.g. used for choices - if( !state.inStringEvaluation ) { - - // We previously found a newline, but were we just double checking that - // it wouldn't immediately be removed by glue? - if( stateAtLastNewline != null ) { - - // Cover cases that non-text generated content was evaluated last step - string currText = currentText; - int prevTextLength = stateAtLastNewline.currentText.Length; - - // Take tags into account too, so that a tag following a content line: - // Content - // # tag - // ... doesn't cause the tag to be wrongly associated with the content above. - int prevTagCount = stateAtLastNewline.currentTags.Count; - - // Output has been extended? - if( !currText.Equals(stateAtLastNewline.currentText) || prevTagCount != currentTags.Count ) { + if (lists != null) + _listDefinitions = new ListDefinitionsOrigin (lists); + + _externals = new Dictionary (); + } + + /// + /// Construct a Story object using a JSON string compiled through inklecate. + /// + public Story(string jsonString) : this((Container)null) + { + Dictionary rootObject = SimpleJson.TextToDictionary (jsonString); + + object versionObj = rootObject ["inkVersion"]; + if (versionObj == null) + throw new System.Exception ("ink version number not found. Are you sure it's a valid .ink.json file?"); + + int formatFromFile = (int)versionObj; + if (formatFromFile > inkVersionCurrent) { + throw new System.Exception ("Version of ink used to build story was newer than the current version of the engine"); + } else if (formatFromFile < inkVersionMinimumCompatible) { + throw new System.Exception ("Version of ink used to build story is too old to be loaded by this version of the engine"); + } else if (formatFromFile != inkVersionCurrent) { + System.Diagnostics.Debug.WriteLine ("WARNING: Version of ink used to build story doesn't match current version of engine. Non-critical, but recommend synchronising."); + } + + var rootToken = rootObject ["root"]; + if (rootToken == null) + throw new System.Exception ("Root node for ink not found. Are you sure it's a valid .ink.json file?"); + + object listDefsObj; + if (rootObject.TryGetValue ("listDefs", out listDefsObj)) { + _listDefinitions = Json.JTokenToListDefinitions (listDefsObj); + } + + _mainContentContainer = Json.JTokenToRuntimeObject (rootToken) as Container; + + ResetState (); + } + + /// + /// The Story itself in JSON representation. + /// + public string ToJson() + { + //return ToJsonOld(); + var writer = new SimpleJson.Writer(); + ToJson(writer); + return writer.ToString(); + } + + /// + /// The Story itself in JSON representation. + /// + public void ToJson(Stream stream) + { + var writer = new SimpleJson.Writer(stream); + ToJson(writer); + } + + void ToJson(SimpleJson.Writer writer) + { + writer.WriteObjectStart(); + + writer.WriteProperty("inkVersion", inkVersionCurrent); + + // Main container content + writer.WriteProperty("root", w => Json.WriteRuntimeContainer(w, _mainContentContainer)); + + // List definitions + if (_listDefinitions != null) { + + writer.WritePropertyStart("listDefs"); + writer.WriteObjectStart(); + + foreach (ListDefinition def in _listDefinitions.lists) + { + writer.WritePropertyStart(def.name); + writer.WriteObjectStart(); + + foreach (var itemToVal in def.items) + { + InkListItem item = itemToVal.Key; + int val = itemToVal.Value; + writer.WriteProperty(item.itemName, val); + } + + writer.WriteObjectEnd(); + writer.WritePropertyEnd(); + } + + writer.WriteObjectEnd(); + writer.WritePropertyEnd(); + } + + writer.WriteObjectEnd(); + } + + /// + /// Reset the Story back to its initial state as it was when it was + /// first constructed. + /// + public void ResetState() + { + // TODO: Could make this possible + IfAsyncWeCant ("ResetState"); + + _state = new StoryState (this); + _state.variablesState.variableChangedEvent += VariableStateDidChangeEvent; + + ResetGlobals (); + } + + /// + /// Reset the runtime error and warning list within the state. + /// + public void ResetErrors() + { + _state.ResetErrors (); + } + + /// + /// Unwinds the callstack. Useful to reset the Story's evaluation + /// without actually changing any meaningful state, for example if + /// you want to exit a section of story prematurely and tell it to + /// go elsewhere with a call to ChoosePathString(...). + /// Doing so without calling ResetCallstack() could cause unexpected + /// issues if, for example, the Story was in a tunnel already. + /// + public void ResetCallstack() + { + IfAsyncWeCant ("ResetCallstack"); + + _state.ForceEnd (); + } + + void ResetGlobals() + { + if (_mainContentContainer.namedContent.ContainsKey ("global decl")) { + var originalPointer = state.currentPointer; + + ChoosePath (new Path ("global decl"), incrementingTurnIndex: false); + + // Continue, but without validating external bindings, + // since we may be doing this reset at initialisation time. + ContinueInternal (); + + state.currentPointer = originalPointer; + } + + state.variablesState.SnapshotDefaultGlobals (); + } + + /// + /// Continue the story for one line of content, if possible. + /// If you're not sure if there's more content available, for example if you + /// want to check whether you're at a choice point or at the end of the story, + /// you should call canContinue before calling this function. + /// + /// The line of text content. + public string Continue() + { + ContinueAsync(0); + return currentText; + } + + + /// + /// Check whether more content is available if you were to call Continue() - i.e. + /// are we mid story rather than at a choice point or at the end. + /// + /// true if it's possible to call Continue(). + public bool canContinue { + get { + return state.canContinue; + } + } + + /// + /// If ContinueAsync was called (with milliseconds limit > 0) then this property + /// will return false if the ink evaluation isn't yet finished, and you need to call + /// it again in order for the Continue to fully complete. + /// + public bool asyncContinueComplete { + get { + return !_asyncContinueActive; + } + } + + /// + /// An "asnychronous" version of Continue that only partially evaluates the ink, + /// with a budget of a certain time limit. It will exit ink evaluation early if + /// the evaluation isn't complete within the time limit, with the + /// asyncContinueComplete property being false. + /// This is useful if ink evaluation takes a long time, and you want to distribute + /// it over multiple game frames for smoother animation. + /// If you pass a limit of zero, then it will fully evaluate the ink in the same + /// way as calling Continue (and in fact, this exactly what Continue does internally). + /// + public void ContinueAsync (float millisecsLimitAsync) + { + if( !_hasValidatedExternals ) + ValidateExternalBindings (); + + ContinueInternal (millisecsLimitAsync); + } + + void ContinueInternal (float millisecsLimitAsync = 0) + { + if( _profiler != null ) + _profiler.PreContinue(); + + var isAsyncTimeLimited = millisecsLimitAsync > 0; + + _recursiveContinueCount++; + + // Doing either: + // - full run through non-async (so not active and don't want to be) + // - Starting async run-through + if (!_asyncContinueActive) { + _asyncContinueActive = isAsyncTimeLimited; + + if (!canContinue) { + throw new StoryException ("Can't continue - should check canContinue before calling Continue"); + } + + _state.didSafeExit = false; + _state.ResetOutput (); + + // 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 (_recursiveContinueCount == 1) + _state.variablesState.batchObservingVariableChanges = true; + } + + // Start timing + var durationStopwatch = new Stopwatch (); + durationStopwatch.Start (); + + bool outputStreamEndsInNewline = false; + do { + + try { + outputStreamEndsInNewline = ContinueSingleStep (); + } catch(StoryException e) { + AddError (e.Message, useEndLineNumber:e.useEndLineNumber); + break; + } + + if (outputStreamEndsInNewline) + break; + + // Run out of async time? + if (_asyncContinueActive && durationStopwatch.ElapsedMilliseconds > millisecsLimitAsync) { + break; + } + + } while(canContinue); + + durationStopwatch.Stop (); + + // 4 outcomes: + // - got newline (so finished this line of text) + // - can't continue (e.g. choices or ending) + // - ran out of time during evaluation + // - error + // + // Successfully finished evaluation in time (or in error) + if (outputStreamEndsInNewline || !canContinue) { + + // Need to rewind, due to evaluating further than we should? + if( _stateSnapshotAtLastNewline != null ) { + RestoreStateSnapshot (); + } + + // Finished a section of content / reached a choice point? + if( !canContinue ) { + if (state.callStack.canPopThread) + AddError ("Thread available to pop, threads should always be flat by the end of evaluation?"); + + if (state.generatedChoices.Count == 0 && !state.didSafeExit && _temporaryEvaluationContainer == null) { + if (state.callStack.CanPop (PushPopType.Tunnel)) + AddError ("unexpectedly reached end of content. Do you need a '->->' to return from a tunnel?"); + else if (state.callStack.CanPop (PushPopType.Function)) + AddError ("unexpectedly reached end of content. Do you need a '~ return'?"); + else if (!state.callStack.canPop) + AddError ("ran out of content. Do you need a '-> DONE' or '-> END'?"); + else + AddError ("unexpectedly reached end of content for unknown reason. Please debug compiler!"); + } + } + + state.didSafeExit = false; + + if (_recursiveContinueCount == 1) + _state.variablesState.batchObservingVariableChanges = false; + + _asyncContinueActive = false; + if(onDidContinue != null) onDidContinue(); + } + + _recursiveContinueCount--; + + if( _profiler != null ) + _profiler.PostContinue(); + } + + bool ContinueSingleStep () + { + if (_profiler != null) + _profiler.PreStep (); + + // Run main step function (walks through content) + Step (); + + if (_profiler != null) + _profiler.PostStep (); + + // Run out of content and we have a default invisible choice that we can follow? + if (!canContinue && !state.callStack.elementIsEvaluateFromGame) { + TryFollowDefaultInvisibleChoice (); + } + + if (_profiler != null) + _profiler.PreSnapshot (); + + // Don't save/rewind during string evaluation, which is e.g. used for choices + if (!state.inStringEvaluation) { + + // We previously found a newline, but were we just double checking that + // it wouldn't immediately be removed by glue? + if (_stateSnapshotAtLastNewline != null) { + + // Has proper text or a tag been added? Then we know that the newline + // that was previously added is definitely the end of the line. + var change = CalculateNewlineOutputStateChange ( + _stateSnapshotAtLastNewline.currentText, state.currentText, + _stateSnapshotAtLastNewline.currentTags.Count, state.currentTags.Count + ); + + // 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 == OutputStateChange.ExtendedBeyondNewline) { + RestoreStateSnapshot (); + + // Hit a newline for sure, we're done + return true; + } + + // Newline that previously existed is no longer valid - e.g. + // glue was encounted that caused it to be removed. + else if (change == OutputStateChange.NewlineRemoved) { + DiscardSnapshot(); + } + } + + // Current content ends in a newline - approaching end of our evaluation + if (state.outputStreamEndsInNewline) { + + // If we can continue evaluation for a bit: + // Create a snapshot in case we need to rewind. + // We're going to continue stepping in case we see glue or some + // non-text content such as choices. + if (canContinue) { + + // Don't bother to record the state beyond the current newline. + // e.g.: + // Hello world\n // record state at the end of here + // ~ complexCalculation() // don't actually need this unless it generates text + if (_stateSnapshotAtLastNewline == null) + StateSnapshot (); + } + + // Can't continue, so we're about to exit - make sure we + // don't have an old state hanging around. + else { + DiscardSnapshot(); + } + + } + + } + + if (_profiler != null) + _profiler.PostSnapshot (); + + // outputStreamEndsInNewline = false + return false; + } + + + + + // Assumption: prevText is the snapshot where we saw a newline, and we're checking whether we're really done + // with that line. Therefore prevText will definitely end in a newline. + // + // We take tags into account too, so that a tag following a content line: + // Content + // # tag + // ... doesn't cause the tag to be wrongly associated with the content above. + enum OutputStateChange + { + NoChange, + ExtendedBeyondNewline, + NewlineRemoved + } + OutputStateChange CalculateNewlineOutputStateChange (string prevText, string currText, int prevTagCount, int currTagCount) + { + // Simple case: nothing's changed, and we still have a newline + // at the end of the current content + var newlineStillExists = currText.Length >= prevText.Length && currText [prevText.Length - 1] == '\n'; + if (prevTagCount == currTagCount && prevText.Length == currText.Length + && newlineStillExists) + return OutputStateChange.NoChange; + + // Old newline has been removed, it wasn't the end of the line after all + if (!newlineStillExists) { + return OutputStateChange.NewlineRemoved; + } + + // Tag added - definitely the start of a new line + if (currTagCount > prevTagCount) + return OutputStateChange.ExtendedBeyondNewline; + + // There must be new content - check whether it's just whitespace + for (int i = prevText.Length; i < currText.Length; i++) { + var c = currText [i]; + if (c != ' ' && c != '\t') { + return OutputStateChange.ExtendedBeyondNewline; + } + } + + // There's new text but it's just spaces and tabs, so there's still the potential + // for glue to kill the newline. + return OutputStateChange.NoChange; + } + + + /// + /// Continue the story until the next choice point or until it runs out of content. + /// This is as opposed to the Continue() method which only evaluates one line of + /// output at a time. + /// + /// The resulting text evaluated by the ink engine, concatenated together. + public string ContinueMaximally() + { + IfAsyncWeCant ("ContinueMaximally"); + + var sb = new StringBuilder (); + + while (canContinue) { + sb.Append (Continue ()); + } + + return sb.ToString (); + } + + internal SearchResult ContentAtPath(Path path) + { + return mainContentContainer.ContentAtPath (path); + } + + internal Runtime.Container KnotContainerWithName (string name) + { + INamedContent namedContainer; + if (mainContentContainer.namedContent.TryGetValue (name, out namedContainer)) + return namedContainer as Container; + else + return null; + } + + internal Pointer PointerAtPath (Path path) + { + if (path.length == 0) + return Pointer.Null; + + var p = new Pointer (); + + int pathLengthToUse = path.length; + + SearchResult result; + if( path.lastComponent.isIndex ) { + pathLengthToUse = path.length - 1; + result = mainContentContainer.ContentAtPath (path, partialPathLength:pathLengthToUse); + p.container = result.container; + p.index = path.lastComponent.index; + } else { + result = mainContentContainer.ContentAtPath (path); + p.container = result.container; + p.index = -1; + } + + if (result.obj == null || result.obj == mainContentContainer && pathLengthToUse > 0) + Error ("Failed to find content at path '" + path + "', and no approximation of it was possible."); + else if (result.approximate) + Warning ("Failed to find content at path '" + path + "', so it was approximated to: '"+result.obj.path+"'."); + + return p; + } + + // Maximum snapshot stack: + // - stateSnapshotDuringSave -- not retained, but returned to game code + // - _stateSnapshotAtLastNewline (has older patch) + // - _state (current, being patched) + + void StateSnapshot() + { + _stateSnapshotAtLastNewline = _state; + _state = _state.CopyAndStartPatching(); + } + + void RestoreStateSnapshot() + { + // Patched state had temporarily hijacked our + // VariablesState 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 VariablesState the old patch. + _stateSnapshotAtLastNewline.RestoreAfterPatch(); + + _state = _stateSnapshotAtLastNewline; + _stateSnapshotAtLastNewline = null; + + // If save completed while the above snapshot was + // active, we need to apply any changes made since + // the save was started but before the snapshot was made. + if( !_asyncSaving ) { + _state.ApplyAnyPatch(); + } + } + + void DiscardSnapshot() + { + // Normally we want to integrate the patch + // into the main global/counts dictionaries. + // However, if we're in the middle of async + // saving, we simply stay in a "patching" state, + // albeit with the newer cloned patch. + if( !_asyncSaving ) + _state.ApplyAnyPatch(); + + // No longer need the snapshot. + _stateSnapshotAtLastNewline = null; + } + + /// + /// Advanced usage! + /// If you have a large story, and saving state to JSON takes too long for your + /// framerate, you can temporarily freeze a copy of the state for saving on + /// a separate thread. Internally, the engine maintains a "diff patch". + /// When you've finished saving your state, call BackgroundSaveComplete() + /// and that diff patch will be applied, allowing the story to continue + /// in its usual mode. + /// + /// The state for background thread save. + public StoryState CopyStateForBackgroundThreadSave() + { + IfAsyncWeCant("start saving on a background thread"); + if (_asyncSaving) throw new System.Exception("Story is already in background saving mode, can't call CopyStateForBackgroundThreadSave again!"); + var stateToSave = _state; + _state = _state.CopyAndStartPatching(); + _asyncSaving = true; + return stateToSave; + } + + /// + /// See CopyStateForBackgroundThreadSave. This method releases the + /// "frozen" save state, applying its patch that it was using internally. + /// + public void BackgroundSaveComplete() + { + // CopyStateForBackgroundThreadSave must be called outside + // of any async ink evaluation, since otherwise you'd be saving + // during an intermediate state. + // However, it's possible to *complete* the save in the middle of + // a glue-lookahead when there's a state stored in _stateSnapshotAtLastNewline. + // This state will have its own patch that is newer than the save patch. + // We hold off on the final apply until the glue-lookahead is finished. + // In that case, the apply is always done, it's just that it may + // apply the looked-ahead changes OR it may simply apply the changes + // made during the save process to the old _stateSnapshotAtLastNewline state. + if ( _stateSnapshotAtLastNewline == null ) { + _state.ApplyAnyPatch(); + } + + _asyncSaving = false; + } + + + + void Step () + { + bool shouldAddToStream = true; + + // Get current content + var pointer = state.currentPointer; + if (pointer.isNull) { + return; + } + + // Step directly to the first element of content in a container (if necessary) + Container containerToEnter = pointer.Resolve () as Container; + while(containerToEnter) { + + // Mark container as being entered + VisitContainer (containerToEnter, atStart:true); + + // No content? the most we can do is step past it + if (containerToEnter.content.Count == 0) + break; + + + pointer = Pointer.StartOf (containerToEnter); + containerToEnter = pointer.Resolve() as Container; + } + state.currentPointer = pointer; + + if( _profiler != null ) { + _profiler.Step(state.callStack); + } + + // 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) + var currentContentObj = pointer.Resolve (); + bool isLogicOrFlowControl = PerformLogicAndFlowControl (currentContentObj); + + // Has flow been forced to end by flow control above? + if (state.currentPointer.isNull) { + return; + } + + if (isLogicOrFlowControl) { + shouldAddToStream = false; + } + + // Choice with condition? + var choicePoint = currentContentObj as ChoicePoint; + if (choicePoint) { + var choice = ProcessChoice (choicePoint); + if (choice) { + state.generatedChoices.Add (choice); + } + + currentContentObj = null; + shouldAddToStream = false; + } + + // If the container has no content, then it will be + // the "content" itself, but we skip over it. + if (currentContentObj is Container) { + shouldAddToStream = false; + } + + // Content to add to evaluation stack or the output stream + if (shouldAddToStream) { + + // If we're pushing a variable pointer onto the evaluation stack, ensure that it's specific + // to our current (possibly temporary) context index. And make a copy of the pointer + // so that we're not editing the original runtime object. + var varPointer = currentContentObj as VariablePointerValue; + if (varPointer && varPointer.contextIndex == -1) { + + // Create new object so we're not overwriting the story's own data + var contextIdx = state.callStack.ContextForVariableNamed(varPointer.variableName); + currentContentObj = new VariablePointerValue (varPointer.variableName, contextIdx); + } + + // Expression evaluation content + if (state.inExpressionEvaluation) { + state.PushEvaluationStack (currentContentObj); + } + // Output stream content (i.e. not expression evaluation) + else { + state.PushToOutputStream (currentContentObj); + } + } + + // Increment the content pointer, following diverts if necessary + NextContent (); + + // 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. + var controlCmd = currentContentObj as ControlCommand; + if (controlCmd && controlCmd.commandType == ControlCommand.CommandType.StartThread) { + state.callStack.PushThread (); + } + } + + // Mark a container as having been visited + void VisitContainer(Container container, bool atStart) + { + if ( !container.countingAtStartOnly || atStart ) { + if( container.visitsShouldBeCounted ) + state.IncrementVisitCountForContainer (container); + + if (container.turnIndexShouldBeCounted) + state.RecordTurnIndexVisitToContainer (container); + } + } + + List _prevContainers = new List(); + void VisitChangedContainersDueToDivert() + { + var previousPointer = state.previousPointer; + var pointer = state.currentPointer; + + // 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. + if (pointer.isNull || pointer.index == -1) + return; + + // First, find the previously open set of containers + _prevContainers.Clear(); + if (!previousPointer.isNull) { + Container prevAncestor = previousPointer.Resolve() as Container ?? previousPointer.container as Container; + while (prevAncestor) { + _prevContainers.Add (prevAncestor); + prevAncestor = prevAncestor.parent as Container; + } + } + + // If the new object is a container itself, it will be visited automatically at the next actual + // content step. However, we need to walk up the new ancestry to see if there are more new containers + Runtime.Object currentChildOfContainer = pointer.Resolve(); + + // Invalid pointer? May happen if attemptingto + if (currentChildOfContainer == null) return; + + Container currentContainerAncestor = currentChildOfContainer.parent as Container; + + bool allChildrenEnteredAtStart = true; + while (currentContainerAncestor && (!_prevContainers.Contains(currentContainerAncestor) || currentContainerAncestor.countingAtStartOnly)) { + + // Check whether this ancestor container is being entered at the start, + // by checking whether the child object is the first. + bool enteringAtStart = currentContainerAncestor.content.Count > 0 + && currentChildOfContainer == currentContainerAncestor.content [0] + && allChildrenEnteredAtStart; + + // Don't count it as entering at start if we're entering random somewhere within + // a container B that happens to be nested at index 0 of container A. It only counts + // if we're diverting directly to the first leaf node. + if (!enteringAtStart) + allChildrenEnteredAtStart = false; + + // Mark a visit to this container + VisitContainer (currentContainerAncestor, enteringAtStart); + + currentChildOfContainer = currentContainerAncestor; + currentContainerAncestor = currentContainerAncestor.parent as Container; + } + } + + Choice ProcessChoice(ChoicePoint choicePoint) + { + bool showChoice = true; + + // Don't create choice if choice point doesn't pass conditional + if (choicePoint.hasCondition) { + var conditionValue = state.PopEvaluationStack (); + if (!IsTruthy (conditionValue)) { + showChoice = false; + } + } + + string startText = ""; + string choiceOnlyText = ""; + + if (choicePoint.hasChoiceOnlyContent) { + var choiceOnlyStrVal = state.PopEvaluationStack () as StringValue; + choiceOnlyText = choiceOnlyStrVal.value; + } + + if (choicePoint.hasStartContent) { + var startStrVal = state.PopEvaluationStack () as StringValue; + startText = startStrVal.value; + } + + // Don't create choice if player has already read this content + if (choicePoint.onceOnly) { + var visitCount = state.VisitCountForContainer (choicePoint.choiceTarget); + if (visitCount > 0) { + showChoice = false; + } + } + + // We go through the full process of creating the choice above so + // that we consume the content for it, since otherwise it'll + // be shown on the output stream. + if (!showChoice) { + return null; + } + + var choice = new Choice (); + choice.targetPath = choicePoint.pathOnChoice; + choice.sourcePath = choicePoint.path.ToString (); + choice.isInvisibleDefault = choicePoint.isInvisibleDefault; + + // 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.threadAtGeneration = state.callStack.ForkThread(); + + // Set final text for the choice + choice.text = (startText + choiceOnlyText).Trim(' ', '\t'); + + return choice; + } + + // Does the expression result represented by this object evaluate to true? + // e.g. is it a Number that's not equal to 1? + bool IsTruthy(Runtime.Object obj) + { + bool truthy = false; + if (obj is Value) { + var val = (Value)obj; + + if (val is DivertTargetValue) { + var divTarget = (DivertTargetValue)val; + Error ("Shouldn't use a divert target (to " + divTarget.targetPath + ") as a conditional value. Did you intend a function call 'likeThis()' or a read count check 'likeThis'? (no arrows)"); + return false; + } + + return val.isTruthy; + } + return truthy; + } + + /// + /// Checks whether contentObj is a control or flow object rather than a piece of content, + /// and performs the required command if necessary. + /// + /// true if object was logic or flow control, false if it's normal content. + /// Content object. + bool PerformLogicAndFlowControl(Runtime.Object contentObj) + { + if( contentObj == null ) { + return false; + } + + // Divert + if (contentObj is Divert) { + + Divert currentDivert = (Divert)contentObj; + + if (currentDivert.isConditional) { + var conditionValue = state.PopEvaluationStack (); + + // False conditional? Cancel divert + if (!IsTruthy (conditionValue)) + return true; + } + + if (currentDivert.hasVariableTarget) { + var varName = currentDivert.variableDivertName; + + var varContents = state.variablesState.GetVariableWithName (varName); + + if (varContents == null) { + Error ("Tried to divert using a target from a variable that could not be found (" + varName + ")"); + } + else if (!(varContents is DivertTargetValue)) { + + var intContent = varContents as IntValue; + + string errorMessage = "Tried to divert to a target from a variable, but the variable (" + varName + ") didn't contain a divert target, it "; + if (intContent && intContent.value == 0) { + errorMessage += "was empty/null (the value 0)."; + } else { + errorMessage += "contained '" + varContents + "'."; + } + + Error (errorMessage); + } + + var target = (DivertTargetValue)varContents; + state.divertedPointer = PointerAtPath(target.targetPath); + + } else if (currentDivert.isExternal) { + CallExternalFunction (currentDivert.targetPathString, currentDivert.externalArgs); + return true; + } else { + state.divertedPointer = currentDivert.targetPointer; + } + + if (currentDivert.pushesToStack) { + state.callStack.Push ( + currentDivert.stackPushType, + outputStreamLengthWithPushed:state.outputStream.Count + ); + } + + if (state.divertedPointer.isNull && !currentDivert.isExternal) { + + // Human readable name available - runtime divert is part of a hard-written divert that to missing content + if (currentDivert && currentDivert.debugMetadata.sourceName != null) { + Error ("Divert target doesn't exist: " + currentDivert.debugMetadata.sourceName); + } else { + Error ("Divert resolution failed: " + currentDivert); + } + } + + return true; + } + + // Start/end an expression evaluation? Or print out the result? + else if( contentObj is ControlCommand ) { + var evalCommand = (ControlCommand) contentObj; + + switch (evalCommand.commandType) { + + case ControlCommand.CommandType.EvalStart: + Assert (state.inExpressionEvaluation == false, "Already in expression evaluation?"); + state.inExpressionEvaluation = true; + break; + + case ControlCommand.CommandType.EvalEnd: + Assert (state.inExpressionEvaluation == true, "Not in expression evaluation mode"); + state.inExpressionEvaluation = false; + break; + + case ControlCommand.CommandType.EvalOutput: + + // If the expression turned out to be empty, there may not be anything on the stack + if (state.evaluationStack.Count > 0) { + + var output = state.PopEvaluationStack (); + + // Functions may evaluate to Void, in which case we skip output + if (!(output is Void)) { + // TODO: Should we really always blanket convert to string? + // It would be okay to have numbers in the output stream the + // only problem is when exporting text for viewing, it skips over numbers etc. + var text = new StringValue (output.ToString ()); + + state.PushToOutputStream (text); + } + + } + break; + + case ControlCommand.CommandType.NoOp: + break; + + case ControlCommand.CommandType.Duplicate: + state.PushEvaluationStack (state.PeekEvaluationStack ()); + break; + + case ControlCommand.CommandType.PopEvaluatedValue: + state.PopEvaluationStack (); + break; + + case ControlCommand.CommandType.PopFunction: + case ControlCommand.CommandType.PopTunnel: + + var popType = evalCommand.commandType == ControlCommand.CommandType.PopFunction ? + PushPopType.Function : PushPopType.Tunnel; + + // Tunnel onwards is allowed to specify an optional override + // divert to go to immediately after returning: ->-> target + DivertTargetValue overrideTunnelReturnTarget = null; + if (popType == PushPopType.Tunnel) { + var popped = state.PopEvaluationStack (); + overrideTunnelReturnTarget = popped as DivertTargetValue; + if (overrideTunnelReturnTarget == null) { + Assert (popped is Void, "Expected void if ->-> doesn't override target"); + } + } + + if (state.TryExitFunctionEvaluationFromGame ()) { + break; + } + else if (state.callStack.currentElement.type != popType || !state.callStack.canPop) { + + var names = new Dictionary (); + names [PushPopType.Function] = "function return statement (~ return)"; + names [PushPopType.Tunnel] = "tunnel onwards statement (->->)"; + + string expected = names [state.callStack.currentElement.type]; + if (!state.callStack.canPop) { + expected = "end of flow (-> END or choice)"; + } + + var errorMsg = string.Format ("Found {0}, when expected {1}", names [popType], expected); + + Error (errorMsg); + } + + else { + state.PopCallstack (); + + // Does tunnel onwards override by diverting to a new ->-> target? + if( overrideTunnelReturnTarget ) + state.divertedPointer = PointerAtPath (overrideTunnelReturnTarget.targetPath); + } + + break; + + case ControlCommand.CommandType.BeginString: + state.PushToOutputStream (evalCommand); + + Assert (state.inExpressionEvaluation == true, "Expected to be in an expression when evaluating a string"); + state.inExpressionEvaluation = false; + break; + + case ControlCommand.CommandType.EndString: + + // Since we're iterating backward through the content, + // build a stack so that when we build the string, + // it's in the right order + var contentStackForString = new Stack (); + + int outputCountConsumed = 0; + for (int i = state.outputStream.Count - 1; i >= 0; --i) { + var obj = state.outputStream [i]; + + outputCountConsumed++; + + var command = obj as ControlCommand; + if (command != null && command.commandType == ControlCommand.CommandType.BeginString) { + break; + } + + if( obj is StringValue ) + contentStackForString.Push (obj); + } + + // Consume the content that was produced for this string + state.PopFromOutputStream (outputCountConsumed); + + // Build string out of the content we collected + var sb = new StringBuilder (); + foreach (var c in contentStackForString) { + sb.Append (c.ToString ()); + } + + // Return to expression evaluation (from content mode) + state.inExpressionEvaluation = true; + state.PushEvaluationStack (new StringValue (sb.ToString ())); + break; + + case ControlCommand.CommandType.ChoiceCount: + var choiceCount = state.generatedChoices.Count; + state.PushEvaluationStack (new Runtime.IntValue (choiceCount)); + break; + + case ControlCommand.CommandType.Turns: + state.PushEvaluationStack (new IntValue (state.currentTurnIndex+1)); + break; + + case ControlCommand.CommandType.TurnsSince: + case ControlCommand.CommandType.ReadCount: + var target = state.PopEvaluationStack(); + if( !(target is DivertTargetValue) ) { + string extraNote = ""; + if( target is IntValue ) + extraNote = ". Did you accidentally pass a read count ('knot_name') instead of a target ('-> knot_name')?"; + Error("TURNS_SINCE expected a divert target (knot, stitch, label name), but saw "+target+extraNote); + break; + } + + var divertTarget = target as DivertTargetValue; + var container = ContentAtPath (divertTarget.targetPath).correctObj as Container; + + int eitherCount; + if (container != null) { + if (evalCommand.commandType == ControlCommand.CommandType.TurnsSince) + eitherCount = state.TurnsSinceForContainer (container); + else + eitherCount = state.VisitCountForContainer (container); + } else { + if (evalCommand.commandType == ControlCommand.CommandType.TurnsSince) + eitherCount = -1; // turn count, default to never/unknown + else + eitherCount = 0; // visit count, assume 0 to default to allowing entry + + Warning ("Failed to find container for " + evalCommand.ToString () + " lookup at " + divertTarget.targetPath.ToString ()); + } + + state.PushEvaluationStack (new IntValue (eitherCount)); + break; + + + case ControlCommand.CommandType.Random: { + var maxInt = state.PopEvaluationStack () as IntValue; + var minInt = state.PopEvaluationStack () as IntValue; + + if (minInt == null) + Error ("Invalid value for minimum parameter of RANDOM(min, max)"); + + if (maxInt == null) + Error ("Invalid value for maximum parameter of RANDOM(min, max)"); + + // +1 because it's inclusive of min and max, for e.g. RANDOM(1,6) for a dice roll. + int randomRange; + try { + randomRange = checked(maxInt.value - minInt.value + 1); + } catch (System.OverflowException) { + randomRange = int.MaxValue; + Error("RANDOM was called with a range that exceeds the size that ink numbers can use."); + } + if (randomRange <= 0) + Error ("RANDOM was called with minimum as " + minInt.value + " and maximum as " + maxInt.value + ". The maximum must be larger"); + + var resultSeed = state.storySeed + state.previousRandom; + var random = new Random (resultSeed); + + var nextRandom = random.Next (); + var chosenValue = (nextRandom % randomRange) + minInt.value; + state.PushEvaluationStack (new IntValue (chosenValue)); + + // Next random number (rather than keeping the Random object around) + state.previousRandom = nextRandom; + break; + } + + case ControlCommand.CommandType.SeedRandom: + var seed = state.PopEvaluationStack () as IntValue; + if (seed == null) + Error ("Invalid value passed to SEED_RANDOM"); + + // Story seed affects both RANDOM and shuffle behaviour + state.storySeed = seed.value; + state.previousRandom = 0; + + // SEED_RANDOM returns nothing. + state.PushEvaluationStack (new Runtime.Void ()); + break; + + case ControlCommand.CommandType.VisitIndex: + var count = state.VisitCountForContainer(state.currentPointer.container) - 1; // index not count + state.PushEvaluationStack (new IntValue (count)); + break; + + case ControlCommand.CommandType.SequenceShuffleIndex: + var shuffleIndex = NextSequenceShuffleIndex (); + state.PushEvaluationStack (new IntValue (shuffleIndex)); + break; + + case ControlCommand.CommandType.StartThread: + // Handled in main step function + break; + + case ControlCommand.CommandType.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.canPopThread) { + state.callStack.PopThread (); + } + + // In normal flow - allow safe exit without warning + else { + state.didSafeExit = true; + + // Stop flow in current thread + state.currentPointer = Pointer.Null; + } + + break; + + // Force flow to end completely + case ControlCommand.CommandType.End: + state.ForceEnd (); + break; + + case ControlCommand.CommandType.ListFromInt: + var intVal = state.PopEvaluationStack () as IntValue; + var listNameVal = state.PopEvaluationStack () as StringValue; + + if (intVal == null) { + throw new StoryException ("Passed non-integer when creating a list element from a numerical value."); + } + + ListValue generatedListValue = null; - // Original newline still exists? - if( currText.Length >= prevTextLength && currText[prevTextLength-1] == '\n' ) { + ListDefinition foundListDef; + if (listDefinitions.TryListGetDefinition (listNameVal.value, out foundListDef)) { + InkListItem foundItem; + if (foundListDef.TryGetItemWithValue (intVal.value, out foundItem)) { + generatedListValue = new ListValue (foundItem, intVal.value); + } + } else { + throw new StoryException ("Failed to find LIST called " + listNameVal.value); + } - RestoreStateSnapshot(stateAtLastNewline); - break; - } + if (generatedListValue == null) + generatedListValue = new ListValue (); - // Newline that previously existed is no longer valid - e.g. - // glue was encounted that caused it to be removed. - else { - stateAtLastNewline = null; - } - } + state.PushEvaluationStack (generatedListValue); + break; - } + case ControlCommand.CommandType.ListRange: { + var max = state.PopEvaluationStack () as Value; + var min = state.PopEvaluationStack () as Value; - // Current content ends in a newline - approaching end of our evaluation - if( state.outputStreamEndsInNewline ) { + var targetList = state.PopEvaluationStack () as ListValue; - // If we can continue evaluation for a bit: - // Create a snapshot in case we need to rewind. - // We're going to continue stepping in case we see glue or some - // non-text content such as choices. - if( canContinue ) { + if (targetList == null || min == null || max == null) + throw new StoryException ("Expected list, minimum and maximum for LIST_RANGE"); - // Don't bother to record the state beyond the current newline. - // e.g.: - // Hello world\n // record state at the end of here - // ~ complexCalculation() // don't actually need this unless it generates text - if( stateAtLastNewline == null ) - stateAtLastNewline = StateSnapshot(); - } + var result = targetList.value.ListWithSubRange(min.valueObject, max.valueObject); - // Can't continue, so we're about to exit - make sure we - // don't have an old state hanging around. - else { - stateAtLastNewline = null; - } + state.PushEvaluationStack (new ListValue(result)); + break; + } - } + case ControlCommand.CommandType.ListRandom: { - } + var listVal = state.PopEvaluationStack () as ListValue; + if (listVal == null) + throw new StoryException ("Expected list for LIST_RANDOM"); + + var list = listVal.value; - } while(canContinue); + InkList newList = null; - // Need to rewind, due to evaluating further than we should? - if( stateAtLastNewline != null ) { - RestoreStateSnapshot(stateAtLastNewline); - } + // List was empty: return empty list + if (list.Count == 0) { + newList = new InkList (); + } - // Finished a section of content / reached a choice point? - if( !canContinue ) { - - if( state.callStack.canPopThread ) { - Error("Thread available to pop, threads should always be flat by the end of evaluation?"); - } - - if( state.generatedChoices.Count == 0 && !state.didSafeExit && _temporaryEvaluationContainer == null ) { - if( state.callStack.CanPop(PushPopType.Tunnel) ) { - Error("unexpectedly reached end of content. Do you need a '->->' to return from a tunnel?"); - } else if( state.callStack.CanPop(PushPopType.Function) ) { - Error("unexpectedly reached end of content. Do you need a '~ return'?"); - } else if( !state.callStack.canPop ) { - Error("ran out of content. Do you need a '-> DONE' or '-> END'?"); - } else { - Error("unexpectedly reached end of content for unknown reason. Please debug compiler!"); - } - } + // Non-empty source list + else { + // Generate a random index for the element to take + var resultSeed = state.storySeed + state.previousRandom; + var random = new Random (resultSeed); - } + var nextRandom = random.Next (); + var listItemIndex = nextRandom % list.Count; + // Iterate through to get the random element + var listEnumerator = list.GetEnumerator (); + for (int i = 0; i <= listItemIndex; i++) { + listEnumerator.MoveNext (); + } + var randomItem = listEnumerator.Current; - } catch(StoryException e) { - AddError (e.Message, e.useEndLineNumber); - } finally { - - state.didSafeExit = false; + // Origin list is simply the origin of the one element + newList = new InkList (randomItem.Key.originName, this); + newList.Add (randomItem.Key, randomItem.Value); - _state.variablesState.batchObservingVariableChanges = false; - } - - return currentText; - } - - /// - /// Check whether more content is available if you were to call Continue() - i.e. - /// are we mid story rather than at a choice point or at the end. - /// - /// true if it's possible to call Continue(). - public bool canContinue - { - get { - return state.canContinue; - } - } - - /// - /// Continue the story until the next choice point or until it runs out of content. - /// This is as opposed to the Continue() method which only evaluates one line of - /// output at a time. - /// - /// The resulting text evaluated by the ink engine, concatenated together. - public string ContinueMaximally() - { - var sb = new StringBuilder (); - - while (canContinue) { - sb.Append (Continue ()); - } - - return sb.ToString (); - } - - internal Runtime.Object ContentAtPath(Path path) - { - return mainContentContainer.ContentAtPath (path); - } - - StoryState StateSnapshot() - { - return state.Copy (); - } - - void RestoreStateSnapshot(StoryState state) - { - _state = state; - } - - void Step () - { - bool shouldAddToStream = true; - - // Get current content - var currentContentObj = state.currentContentObject; - if (currentContentObj == null) { - return; - } - - // Step directly to the first element of content in a container (if necessary) - Container currentContainer = currentContentObj as Container; - while(currentContainer) { - - // Mark container as being entered - VisitContainer (currentContainer, atStart:true); - - // No content? the most we can do is step past it - if (currentContainer.content.Count == 0) - break; - - currentContentObj = currentContainer.content [0]; - state.callStack.currentElement.currentContentIndex = 0; - state.callStack.currentElement.currentContainer = currentContainer; - - currentContainer = currentContentObj as Container; - } - currentContainer = state.callStack.currentElement.currentContainer; - - // 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) - bool isLogicOrFlowControl = PerformLogicAndFlowControl (currentContentObj); - - // Has flow been forced to end by flow control above? - if (state.currentContentObject == null) { - return; - } - - if (isLogicOrFlowControl) { - shouldAddToStream = false; - } - - // Choice with condition? - var choicePoint = currentContentObj as ChoicePoint; - if (choicePoint) { - var choice = ProcessChoice (choicePoint); - if (choice) { - state.generatedChoices.Add (choice); - } - - currentContentObj = null; - shouldAddToStream = false; - } - - // If the container has no content, then it will be - // the "content" itself, but we skip over it. - if (currentContentObj is Container) { - shouldAddToStream = false; - } - - // Content to add to evaluation stack or the output stream - if (shouldAddToStream) { - - // If we're pushing a variable pointer onto the evaluation stack, ensure that it's specific - // to our current (possibly temporary) context index. And make a copy of the pointer - // so that we're not editing the original runtime object. - var varPointer = currentContentObj as VariablePointerValue; - if (varPointer && varPointer.contextIndex == -1) { - - // Create new object so we're not overwriting the story's own data - var contextIdx = state.callStack.ContextForVariableNamed(varPointer.variableName); - currentContentObj = new VariablePointerValue (varPointer.variableName, contextIdx); - } - - // Expression evaluation content - if (state.inExpressionEvaluation) { - state.PushEvaluationStack (currentContentObj); - } - // Output stream content (i.e. not expression evaluation) - else { - state.PushToOutputStream (currentContentObj); - } - } - - // Increment the content pointer, following diverts if necessary - NextContent (); - - // 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. - var controlCmd = currentContentObj as ControlCommand; - if (controlCmd && controlCmd.commandType == ControlCommand.CommandType.StartThread) { - state.callStack.PushThread (); - } - } - - // Mark a container as having been visited - void VisitContainer(Container container, bool atStart) - { - if ( !container.countingAtStartOnly || atStart ) { - if( container.visitsShouldBeCounted ) - IncrementVisitCountForContainer (container); - - if (container.turnIndexShouldBeCounted) - RecordTurnIndexVisitToContainer (container); - } - } - - HashSet _prevContainerSet; - void VisitChangedContainersDueToDivert() - { - var previousContentObject = state.previousContentObject; - var newContentObject = state.currentContentObject; - - if (!newContentObject) - return; - - // First, find the previously open set of containers - if( _prevContainerSet == null ) _prevContainerSet = new HashSet (); - _prevContainerSet.Clear(); - if (previousContentObject) { - Container prevAncestor = previousContentObject as Container ?? previousContentObject.parent as Container; - while (prevAncestor) { - _prevContainerSet.Add (prevAncestor); - prevAncestor = prevAncestor.parent as Container; - } - } - - // If the new object is a container itself, it will be visited automatically at the next actual - // content step. However, we need to walk up the new ancestry to see if there are more new containers - Runtime.Object currentChildOfContainer = newContentObject; - Container currentContainerAncestor = currentChildOfContainer.parent as Container; - while (currentContainerAncestor && !_prevContainerSet.Contains(currentContainerAncestor)) { - - // Check whether this ancestor container is being entered at the start, - // by checking whether the child object is the first. - bool enteringAtStart = currentContainerAncestor.content.Count > 0 - && currentChildOfContainer == currentContainerAncestor.content [0]; - - // Mark a visit to this container - VisitContainer (currentContainerAncestor, enteringAtStart); - - currentChildOfContainer = currentContainerAncestor; - currentContainerAncestor = currentContainerAncestor.parent as Container; - } - } - - Choice ProcessChoice(ChoicePoint choicePoint) - { - bool showChoice = true; - - // Don't create choice if choice point doesn't pass conditional - if (choicePoint.hasCondition) { - var conditionValue = state.PopEvaluationStack (); - if (!IsTruthy (conditionValue)) { - showChoice = false; - } - } - - string startText = ""; - string choiceOnlyText = ""; - - if (choicePoint.hasChoiceOnlyContent) { - var choiceOnlyStrVal = state.PopEvaluationStack () as StringValue; - choiceOnlyText = choiceOnlyStrVal.value; - } - - if (choicePoint.hasStartContent) { - var startStrVal = state.PopEvaluationStack () as StringValue; - startText = startStrVal.value; - } - - // Don't create choice if player has already read this content - if (choicePoint.onceOnly) { - var visitCount = VisitCountForContainer (choicePoint.choiceTarget); - if (visitCount > 0) { - showChoice = false; - } - } - - var choice = new Choice (choicePoint); - choice.threadAtGeneration = state.callStack.currentThread.Copy (); - - // We go through the full process of creating the choice above so - // that we consume the content for it, since otherwise it'll - // be shown on the output stream. - if (!showChoice) { - return null; - } - - // Set final text for the choice - choice.text = startText + choiceOnlyText; - - return choice; - } - - // Does the expression result represented by this object evaluate to true? - // e.g. is it a Number that's not equal to 1? - bool IsTruthy(Runtime.Object obj) - { - bool truthy = false; - if (obj is Value) { - var val = (Value)obj; - - if (val is DivertTargetValue) { - var divTarget = (DivertTargetValue)val; - Error ("Shouldn't use a divert target (to " + divTarget.targetPath + ") as a conditional value. Did you intend a function call 'likeThis()' or a read count check 'likeThis'? (no arrows)"); - return false; - } - - return val.isTruthy; - } - return truthy; - } - - /// - /// Checks whether contentObj is a control or flow object rather than a piece of content, - /// and performs the required command if necessary. - /// - /// true if object was logic or flow control, false if it's normal content. - /// Content object. - bool PerformLogicAndFlowControl(Runtime.Object contentObj) - { - if( contentObj == null ) { - return false; - } - - // Divert - if (contentObj is Divert) { - - Divert currentDivert = (Divert)contentObj; - - if (currentDivert.isConditional) { - var conditionValue = state.PopEvaluationStack (); - - // False conditional? Cancel divert - if (!IsTruthy (conditionValue)) - return true; - } - - if (currentDivert.hasVariableTarget) { - var varName = currentDivert.variableDivertName; - - var varContents = state.variablesState.GetVariableWithName (varName); - - if (!(varContents is DivertTargetValue)) { - - var intContent = varContents as IntValue; - - string errorMessage = "Tried to divert to a target from a variable, but the variable (" + varName + ") didn't contain a divert target, it "; - if (intContent && intContent.value == 0) { - errorMessage += "was empty/null (the value 0)."; - } else { - errorMessage += "contained '" + varContents + "'."; - } - - Error (errorMessage); - } - - var target = (DivertTargetValue)varContents; - state.divertedTargetObject = ContentAtPath(target.targetPath); - - } else if (currentDivert.isExternal) { - CallExternalFunction (currentDivert.targetPathString, currentDivert.externalArgs); - return true; - } else { - state.divertedTargetObject = currentDivert.targetContent; - } - - if (currentDivert.pushesToStack) { - state.callStack.Push (currentDivert.stackPushType); - } - - if (state.divertedTargetObject == null && !currentDivert.isExternal) { - - // Human readable name available - runtime divert is part of a hard-written divert that to missing content - if (currentDivert && currentDivert.debugMetadata.sourceName != null) { - Error ("Divert target doesn't exist: " + currentDivert.debugMetadata.sourceName); - } else { - Error ("Divert resolution failed: " + currentDivert); - } - } - - return true; - } - - // Start/end an expression evaluation? Or print out the result? - else if( contentObj is ControlCommand ) { - var evalCommand = (ControlCommand) contentObj; + state.previousRandom = nextRandom; + } + + state.PushEvaluationStack (new ListValue(newList)); + break; + } + + default: + Error ("unhandled ControlCommand: " + evalCommand); + break; + } + + return true; + } + + // Variable assignment + else if( contentObj is VariableAssignment ) { + var varAss = (VariableAssignment) contentObj; + var assignedVal = state.PopEvaluationStack(); + + // When in temporary evaluation, don't create new variables purely within + // the temporary context, but attempt to create them globally + //var prioritiseHigherInCallStack = _temporaryEvaluationContainer != null; - switch (evalCommand.commandType) { - - case ControlCommand.CommandType.EvalStart: - Assert (state.inExpressionEvaluation == false, "Already in expression evaluation?"); - state.inExpressionEvaluation = true; - break; - - case ControlCommand.CommandType.EvalEnd: - Assert (state.inExpressionEvaluation == true, "Not in expression evaluation mode"); - state.inExpressionEvaluation = false; - break; + state.variablesState.Assign (varAss, assignedVal); - case ControlCommand.CommandType.EvalOutput: + return true; + } + + // Variable reference + else if( contentObj is VariableReference ) { + var varRef = (VariableReference)contentObj; + Runtime.Object foundValue = null; + + + // Explicit read count value + if (varRef.pathForCount != null) { - // If the expression turned out to be empty, there may not be anything on the stack - if (state.evaluationStack.Count > 0) { + var container = varRef.containerForCount; + int count = state.VisitCountForContainer (container); + foundValue = new IntValue (count); + } - var output = state.PopEvaluationStack (); + // Normal variable reference + else { - // Functions may evaluate to Void, in which case we skip output - if (!(output is Void)) { - // TODO: Should we really always blanket convert to string? - // It would be okay to have numbers in the output stream the - // only problem is when exporting text for viewing, it skips over numbers etc. - var text = new StringValue (output.ToString ()); + foundValue = state.variablesState.GetVariableWithName (varRef.name); - state.PushToOutputStream (text); - } + if (foundValue == null) { + Warning ("Variable not found: '" + varRef.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."); + foundValue = new IntValue (0); + } + } - } - break; + state.PushEvaluationStack (foundValue); - case ControlCommand.CommandType.NoOp: - break; + return true; + } - case ControlCommand.CommandType.Duplicate: - state.PushEvaluationStack (state.PeekEvaluationStack ()); - break; + // Native function call + else if (contentObj is NativeFunctionCall) { + var func = (NativeFunctionCall)contentObj; + var funcParams = state.PopEvaluationStack (func.numberOfParameters); + var result = func.Call (funcParams); + state.PushEvaluationStack (result); + return true; + } + + // No control content, must be ordinary content + return false; + } - case ControlCommand.CommandType.PopEvaluatedValue: - state.PopEvaluationStack (); - break; + /// + /// 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. + /// + /// + /// A dot-separted path string, as specified above. + /// Whether to reset the callstack first (see summary description). + /// Optional set of arguments to pass, if path is to a knot that takes them. + public void ChoosePathString (string path, bool resetCallstack = true, params object [] arguments) + { + IfAsyncWeCant ("call ChoosePathString right now"); + if(onChoosePathString != null) onChoosePathString(path, arguments); + if (resetCallstack) { + ResetCallstack (); + } else { + // ChoosePathString 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.currentElement.type == PushPopType.Function) { + string funcDetail = ""; + var container = state.callStack.currentElement.currentPointer.container; + if (container != null) { + funcDetail = "("+container.path.ToString ()+") "; + } + throw new System.Exception ("Story was running a function "+funcDetail+"when you called ChoosePathString("+path+") - this is almost certainly not not what you want! Full stack trace: \n"+state.callStack.callStackTrace); + } + } - case ControlCommand.CommandType.PopFunction: - case ControlCommand.CommandType.PopTunnel: + state.PassArgumentsToEvaluationStack (arguments); + ChoosePath (new Path (path)); + } - var popType = evalCommand.commandType == ControlCommand.CommandType.PopFunction ? - PushPopType.Function : PushPopType.Tunnel; + void IfAsyncWeCant (string activityStr) + { + if (_asyncContinueActive) + throw new System.Exception ("Can't " + activityStr + ". Story is in the middle of a ContinueAsync(). Make more ContinueAsync() calls or a single Continue() call beforehand."); + } + + internal void ChoosePath(Path p, bool incrementingTurnIndex = true) + { + state.SetChosenPath (p, incrementingTurnIndex); - // Tunnel onwards is allowed to specify an optional override - // divert to go to immediately after returning: ->-> target - DivertTargetValue overrideTunnelReturnTarget = null; - if (popType == PushPopType.Tunnel) { - var popped = state.PopEvaluationStack (); - overrideTunnelReturnTarget = popped as DivertTargetValue; - if (overrideTunnelReturnTarget == null) { - Assert (popped is Void, "Expected void if ->-> doesn't override target"); - } - } + // Take a note of newly visited containers for read counts etc + VisitChangedContainersDueToDivert (); + } - if (state.TryExitExternalFunctionEvaluation ()) { - break; - } - else if (state.callStack.currentElement.type != popType || !state.callStack.canPop) { + /// + /// 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. + /// + public void ChooseChoiceIndex(int choiceIdx) + { + var choices = currentChoices; + Assert (choiceIdx >= 0 && choiceIdx < choices.Count, "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 context. + var choiceToChoose = choices [choiceIdx]; + if(onMakeChoice != null) onMakeChoice(choiceToChoose); + state.callStack.currentThread = choiceToChoose.threadAtGeneration; + + ChoosePath (choiceToChoose.targetPath); + } - var names = new Dictionary (); - names [PushPopType.Function] = "function return statement (~ return)"; - names [PushPopType.Tunnel] = "tunnel onwards statement (->->)"; + /// + /// Checks if a function exists. + /// + /// True if the function exists, else false. + /// The name of the function as declared in ink. + public bool HasFunction (string functionName) + { + try { + return KnotContainerWithName (functionName) != null; + } catch { + return false; + } + } - string expected = names [state.callStack.currentElement.type]; - if (!state.callStack.canPop) { - expected = "end of flow (-> END or choice)"; - } + /// + /// Evaluates a function defined in ink. + /// + /// The return value as returned from the ink function with `~ return myValue`, or null if nothing is returned. + /// The name of the function as declared in ink. + /// The arguments that the ink function takes, if any. Note that we don't (can't) do any validation on the number of arguments right now, so make sure you get it right! + public object EvaluateFunction (string functionName, params object [] arguments) + { + string _; + return EvaluateFunction (functionName, out _, arguments); + } - var errorMsg = string.Format ("Found {0}, when expected {1}", names [popType], expected); + /// + /// Evaluates a function defined in ink, and gathers the possibly multi-line text as generated by the function. + /// This text output is any text written as normal content within the function, as opposed to the return value, as returned with `~ return`. + /// + /// The return value as returned from the ink function with `~ return myValue`, or null if nothing is returned. + /// The name of the function as declared in ink. + /// The text content produced by the function via normal ink, if any. + /// The arguments that the ink function takes, if any. Note that we don't (can't) do any validation on the number of arguments right now, so make sure you get it right! + public object EvaluateFunction (string functionName, out string textOutput, params object [] arguments) + { + if(onEvaluateFunction != null) onEvaluateFunction(functionName, arguments); + IfAsyncWeCant ("evaluate a function"); - Error (errorMsg); - } + if(functionName == null) { + throw new System.Exception ("Function is null"); + } else if(functionName == string.Empty || functionName.Trim() == string.Empty) { + throw new System.Exception ("Function is empty or white space."); + } - else { - state.callStack.Pop (); + // Get the content that we need to run + var funcContainer = KnotContainerWithName (functionName); + if( funcContainer == null ) + throw new System.Exception ("Function doesn't exist: '" + functionName + "'"); - // Does tunnel onwards override by diverting to a new ->-> target? - if( overrideTunnelReturnTarget ) - state.divertedTargetObject = ContentAtPath (overrideTunnelReturnTarget.targetPath); - } + // Snapshot the output stream + var outputStreamBefore = new List(state.outputStream); + _state.ResetOutput (); - break; + // State will temporarily replace the callstack in order to evaluate + state.StartFunctionEvaluationFromGame (funcContainer, arguments); - case ControlCommand.CommandType.BeginString: - state.PushToOutputStream (evalCommand); + // Evaluate the function, and collect the string output + var stringOutput = new StringBuilder (); + while (canContinue) { + stringOutput.Append (Continue ()); + } + textOutput = stringOutput.ToString (); - Assert (state.inExpressionEvaluation == true, "Expected to be in an expression when evaluating a string"); - state.inExpressionEvaluation = false; - break; + // Restore the output stream in case this was called + // during main story evaluation. + _state.ResetOutput (outputStreamBefore); - case ControlCommand.CommandType.EndString: + // Finish evaluation, and see whether anything was produced + var result = state.CompleteFunctionEvaluationFromGame (); + if(onCompleteEvaluateFunction != null) onCompleteEvaluateFunction(functionName, arguments, textOutput, result); + return result; + } - // Since we're iterating backward through the content, - // build a stack so that when we build the string, - // it's in the right order - var contentStackForString = new Stack (); + // Evaluate a "hot compiled" piece of ink content, as used by the REPL-like + // CommandLinePlayer. + internal Runtime.Object EvaluateExpression(Runtime.Container exprContainer) + { + int startCallStackHeight = state.callStack.elements.Count; - int outputCountConsumed = 0; - for (int i = state.outputStream.Count - 1; i >= 0; --i) { - var obj = state.outputStream [i]; + state.callStack.Push (PushPopType.Tunnel); - outputCountConsumed++; + _temporaryEvaluationContainer = exprContainer; - var command = obj as ControlCommand; - if (command != null && command.commandType == ControlCommand.CommandType.BeginString) { - break; - } + state.GoToStart (); - if( obj is StringValue ) - contentStackForString.Push (obj); - } + int evalStackHeight = state.evaluationStack.Count; - // Consume the content that was produced for this string - state.outputStream.RemoveRange (state.outputStream.Count - outputCountConsumed, outputCountConsumed); + Continue (); - // Build string out of the content we collected - var sb = new StringBuilder (); - foreach (var c in contentStackForString) { - sb.Append (c.ToString ()); - } + _temporaryEvaluationContainer = null; - // Return to expression evaluation (from content mode) - state.inExpressionEvaluation = true; - state.PushEvaluationStack (new StringValue (sb.ToString ())); - break; + // Should have fallen off the end of the Container, which should + // have auto-popped, but just in case we didn't for some reason, + // manually pop to restore the state (including currentPath). + if (state.callStack.elements.Count > startCallStackHeight) { + state.PopCallstack (); + } - case ControlCommand.CommandType.ChoiceCount: - var choiceCount = state.generatedChoices.Count; - state.PushEvaluationStack (new Runtime.IntValue (choiceCount)); - break; - - case ControlCommand.CommandType.TurnsSince: - case ControlCommand.CommandType.ReadCount: - var target = state.PopEvaluationStack(); - if( !(target is DivertTargetValue) ) { - string extraNote = ""; - if( target is IntValue ) - extraNote = ". Did you accidentally pass a read count ('knot_name') instead of a target ('-> knot_name')?"; - Error("TURNS_SINCE expected a divert target (knot, stitch, label name), but saw "+target+extraNote); - break; - } - - var divertTarget = target as DivertTargetValue; - var container = ContentAtPath (divertTarget.targetPath) as Container; - - int eitherCount; - if (evalCommand.commandType == ControlCommand.CommandType.TurnsSince) - eitherCount = TurnsSinceForContainer (container); - else - eitherCount = VisitCountForContainer (container); - - state.PushEvaluationStack (new IntValue (eitherCount)); - break; - - case ControlCommand.CommandType.Random: - var maxInt = state.PopEvaluationStack () as IntValue; - var minInt = state.PopEvaluationStack () as IntValue; - - if (minInt == null) - Error ("Invalid value for minimum parameter of RANDOM(min, max)"); - - if (maxInt == null) - Error ("Invalid value for maximum parameter of RANDOM(min, max)"); - - // +1 because it's inclusive of min and max, for e.g. RANDOM(1,6) for a dice roll. - var randomRange = maxInt.value - minInt.value + 1; - if (randomRange <= 0) - Error ("RANDOM was called with minimum as " + minInt.value + " and maximum as " + maxInt.value + ". The maximum must be larger"); - - var resultSeed = state.storySeed + state.previousRandom; - var random = new Random(resultSeed); - - var nextRandom = random.Next (); - var chosenValue = (nextRandom % randomRange) + minInt.value; - state.PushEvaluationStack (new IntValue (chosenValue)); - - // Next random number (rather than keeping the Random object around) - state.previousRandom = nextRandom; - break; - - case ControlCommand.CommandType.SeedRandom: - var seed = state.PopEvaluationStack () as IntValue; - if (seed == null) - Error ("Invalid value passed to SEED_RANDOM"); - - // Story seed affects both RANDOM and shuffle behaviour - state.storySeed = seed.value; - state.previousRandom = 0; - - // SEED_RANDOM returns nothing. - state.PushEvaluationStack (new Runtime.Void ()); - break; - - case ControlCommand.CommandType.VisitIndex: - var count = VisitCountForContainer(state.currentContainer) - 1; // index not count - state.PushEvaluationStack (new IntValue (count)); - break; - - case ControlCommand.CommandType.SequenceShuffleIndex: - var shuffleIndex = NextSequenceShuffleIndex (); - state.PushEvaluationStack (new IntValue (shuffleIndex)); - break; - - case ControlCommand.CommandType.StartThread: - // Handled in main step function - break; - - case ControlCommand.CommandType.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.canPopThread) { - state.callStack.PopThread (); - } - - // In normal flow - allow safe exit without warning - else { - state.didSafeExit = true; - - // Stop flow in current thread - state.currentContentObject = null; - } - - break; - - // Force flow to end completely - case ControlCommand.CommandType.End: - state.ForceEnd (); - break; - - case ControlCommand.CommandType.ListFromInt: - var intVal = state.PopEvaluationStack () as IntValue; - var listNameVal = state.PopEvaluationStack () as StringValue; - - if (intVal == null) { - throw new StoryException ("Passed non-integer when creating a list element from a numerical value."); - } + int endStackHeight = state.evaluationStack.Count; + if (endStackHeight > evalStackHeight) { + return state.PopEvaluationStack (); + } else { + return null; + } - ListValue generatedListValue = null; + } - ListDefinition foundListDef; - if (listDefinitions.TryGetDefinition (listNameVal.value, out foundListDef)) { - InkListItem foundItem; - if (foundListDef.TryGetItemWithValue (intVal.value, out foundItem)) { - generatedListValue = new ListValue (foundItem, intVal.value); - } - } else { - throw new StoryException ("Failed to find LIST called " + listNameVal.value); - } + /// + /// An ink file can provide a fallback functions for when when an EXTERNAL has been left + /// unbound by the client, and the fallback function will be called instead. Useful when + /// testing a story in playmode, when it's not possible to write a client-side C# external + /// function, but you don't want it to fail to run. + /// + public bool allowExternalFunctionFallbacks { get; set; } - if (generatedListValue == null) - generatedListValue = new ListValue (); + internal void CallExternalFunction(string funcName, int numberOfArguments) + { + ExternalFunction func = null; + Container fallbackFunctionContainer = null; + + var foundExternal = _externals.TryGetValue (funcName, out func); + + // Try to use fallback function? + if (!foundExternal) { + if (allowExternalFunctionFallbacks) { + fallbackFunctionContainer = KnotContainerWithName (funcName); + Assert (fallbackFunctionContainer != null, "Trying to call EXTERNAL function '" + funcName + "' which has not been bound, and fallback ink function could not be found."); + + // Divert direct into fallback function and we're done + state.callStack.Push ( + PushPopType.Function, + outputStreamLengthWithPushed:state.outputStream.Count + ); + state.divertedPointer = Pointer.StartOf(fallbackFunctionContainer); + return; + + } else { + Assert (false, "Trying to call EXTERNAL function '" + funcName + "' which has not been bound (and ink fallbacks disabled)."); + } + } - state.PushEvaluationStack (generatedListValue); - break; + // Pop arguments + var arguments = new List(); + for (int i = 0; i < numberOfArguments; ++i) { + var poppedObj = state.PopEvaluationStack () as Value; + var valueObj = poppedObj.valueObject; + arguments.Add (valueObj); + } - case ControlCommand.CommandType.ListRange: { - var max = state.PopEvaluationStack (); - var min = state.PopEvaluationStack (); + // Reverse arguments from the order they were popped, + // so they're the right way round again. + arguments.Reverse (); - var targetList = state.PopEvaluationStack () as ListValue; + // Run the function! + object funcResult = func (arguments.ToArray()); - if (targetList == null || min == null || max == null) - throw new StoryException ("Expected list, minimum and maximum for LIST_RANGE"); - - // Allow either int or a particular list item to be passed for the bounds, - // so wrap up a function to handle this casting for us. - Func IntBound = (obj) => { - var listValue = obj as ListValue; - if (listValue) { - return (int)listValue.value.maxItem.Value; - } - - var intValue = obj as IntValue; - if (intValue) { - return intValue.value; - } - - return -1; - }; - - int minVal = IntBound (min); - int maxVal = IntBound (max); - if (minVal == -1) - throw new StoryException ("Invalid min range bound passed to LIST_VALUE(): " + min); - - if (maxVal == -1) - throw new StoryException ("Invalid max range bound passed to LIST_VALUE(): " + max); - - // Extract the range of items from the origin list - ListValue result = new ListValue (); - var origins = targetList.value.origins; - - if (origins != null) { - foreach(var origin in origins) { - var rangeFromOrigin = origin.ListRange (minVal, maxVal); - foreach (var kv in rangeFromOrigin.value) { - result.value [kv.Key] = kv.Value; - } - } - } - - state.PushEvaluationStack (result); - break; - } - - default: - Error ("unhandled ControlCommand: " + evalCommand); - break; - } - - return true; - } - - // Variable assignment - else if( contentObj is VariableAssignment ) { - var varAss = (VariableAssignment) contentObj; - var assignedVal = state.PopEvaluationStack(); - - // When in temporary evaluation, don't create new variables purely within - // the temporary context, but attempt to create them globally - //var prioritiseHigherInCallStack = _temporaryEvaluationContainer != null; - - state.variablesState.Assign (varAss, assignedVal); - - return true; - } - - // Variable reference - else if( contentObj is VariableReference ) { - var varRef = (VariableReference)contentObj; - Runtime.Object foundValue = null; - - - // Explicit read count value - if (varRef.pathForCount != null) { - - var container = varRef.containerForCount; - int count = VisitCountForContainer (container); - foundValue = new IntValue (count); - } - - // Normal variable reference - else { - - foundValue = state.variablesState.GetVariableWithName (varRef.name); - - if (foundValue == null) { - Error("Uninitialised variable: " + varRef.name); - foundValue = new IntValue (0); - } - } - - state.PushEvaluationStack (foundValue); - - return true; - } - - // Native function call - else if (contentObj is NativeFunctionCall) { - var func = (NativeFunctionCall)contentObj; - var funcParams = state.PopEvaluationStack (func.numberOfParameters); - var result = func.Call (funcParams); - state.PushEvaluationStack (result); - return true; - } - - // No control content, must be ordinary content - return false; - } - - /// - /// 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. - /// - /// - /// A dot-separted path string, as specified above. - /// Optional set of arguments to pass, if path is to a knot that takes them. - public void ChoosePathString (string path, params object [] arguments) - { - state.PassArgumentsToEvaluationStack (arguments); - ChoosePath (new Path (path)); - } - - - internal void ChoosePath(Path p) - { - state.SetChosenPath (p); - - // Take a note of newly visited containers for read counts etc - VisitChangedContainersDueToDivert (); - } - - /// - /// 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. - /// - public void ChooseChoiceIndex(int choiceIdx) - { - var choices = currentChoices; - Assert (choiceIdx >= 0 && choiceIdx < choices.Count, "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 context. - var choiceToChoose = choices [choiceIdx]; - state.callStack.currentThread = choiceToChoose.threadAtGeneration; - - ChoosePath (choiceToChoose.choicePoint.choiceTarget.path); - } - - /// - /// Checks if a function exists. - /// - /// True if the function exists, else false. - /// The name of the function as declared in ink. - public bool HasFunction (string functionName) - { - try { - return ContentAtPath (new Path (functionName)) is Runtime.Container; - } catch { - return false; - } - } - - /// - /// Evaluates a function defined in ink. - /// - /// The return value as returned from the ink function with `~ return myValue`, or null if nothing is returned. - /// The name of the function as declared in ink. - /// The arguments that the ink function takes, if any. Note that we don't (can't) do any validation on the number of arguments right now, so make sure you get it right! - public object EvaluateFunction (string functionName, params object [] arguments) - { - string _; - return EvaluateFunction (functionName, out _, arguments); - } - - /// - /// Evaluates a function defined in ink, and gathers the possibly multi-line text as generated by the function. - /// This text output is any text written as normal content within the function, as opposed to the return value, as returned with `~ return`. - /// - /// The return value as returned from the ink function with `~ return myValue`, or null if nothing is returned. - /// The name of the function as declared in ink. - /// The text content produced by the function via normal ink, if any. - /// The arguments that the ink function takes, if any. Note that we don't (can't) do any validation on the number of arguments right now, so make sure you get it right! - public object EvaluateFunction (string functionName, out string textOutput, params object [] arguments) - { - if(functionName == null) { - throw new System.Exception ("Function is null"); - } else if(functionName == string.Empty || functionName.Trim() == string.Empty) { - throw new System.Exception ("Function is empty or white space."); + // Convert return value (if any) to the a type that the ink engine can use + Runtime.Object returnObj = null; + if (funcResult != null) { + returnObj = Value.Create (funcResult); + Assert (returnObj != null, "Could not create ink value from returned object of type " + funcResult.GetType()); + } else { + returnObj = new Runtime.Void (); + } + + state.PushEvaluationStack (returnObj); + } + + /// + /// General purpose delegate definition for bound EXTERNAL function definitions + /// from ink. Note that this version isn't necessary if you have a function + /// with three arguments or less - see the overloads of BindExternalFunction. + /// + public delegate object ExternalFunction(object[] args); + + /// + /// Most general form of function binding that returns an object + /// and takes an array of object parameters. + /// The only way to bind a function with more than 3 arguments. + /// + /// EXTERNAL ink function name to bind to. + /// The C# function to bind. + public void BindExternalFunctionGeneral(string funcName, ExternalFunction func) + { + IfAsyncWeCant ("bind an external function"); + Assert (!_externals.ContainsKey (funcName), "Function '" + funcName + "' has already been bound."); + _externals [funcName] = func; + } + + object TryCoerce(object value) + { + if (value == null) + return null; + + if (value is T) + return (T) value; + + if (value is float && typeof(T) == typeof(int)) { + int intVal = (int)Math.Round ((float)value); + return intVal; + } + + if (value is int && typeof(T) == typeof(float)) { + float floatVal = (float)(int)value; + return floatVal; } - // Get the content that we need to run - Runtime.Container funcContainer = null; - try { - funcContainer = ContentAtPath (new Path (functionName)) as Runtime.Container; - } catch (StoryException e) { - if (e.Message.Contains ("not found")) - throw new System.Exception ("Function doesn't exist: '" + functionName + "'"); - else - throw e; - } - - // State will temporarily replace the callstack in order to evaluate - state.StartExternalFunctionEvaluation (funcContainer, arguments); - - // Evaluate the function, and collect the string output - var stringOutput = new StringBuilder (); - while (canContinue) { - stringOutput.Append (Continue ()); - } - textOutput = stringOutput.ToString (); - - // Finish evaluation, and see whether anything was produced - var result = state.CompleteExternalFunctionEvaluation (); - return result; - } - - // Evaluate a "hot compiled" piece of ink content, as used by the REPL-like - // CommandLinePlayer. - internal Runtime.Object EvaluateExpression(Runtime.Container exprContainer) - { - int startCallStackHeight = state.callStack.elements.Count; - - state.callStack.Push (PushPopType.Tunnel); - - _temporaryEvaluationContainer = exprContainer; - - state.GoToStart (); - - int evalStackHeight = state.evaluationStack.Count; - - Continue (); - - _temporaryEvaluationContainer = null; - - // Should have fallen off the end of the Container, which should - // have auto-popped, but just in case we didn't for some reason, - // manually pop to restore the state (including currentPath). - if (state.callStack.elements.Count > startCallStackHeight) { - state.callStack.Pop (); - } - - int endStackHeight = state.evaluationStack.Count; - if (endStackHeight > evalStackHeight) { - return state.PopEvaluationStack (); - } else { - return null; - } - - } - - /// - /// An ink file can provide a fallback functions for when when an EXTERNAL has been left - /// unbound by the client, and the fallback function will be called instead. Useful when - /// testing a story in playmode, when it's not possible to write a client-side C# external - /// function, but you don't want it to fail to run. - /// - public bool allowExternalFunctionFallbacks { get; set; } - - internal void CallExternalFunction(string funcName, int numberOfArguments) - { - ExternalFunction func = null; - Container fallbackFunctionContainer = null; - - var foundExternal = _externals.TryGetValue (funcName, out func); - - // Try to use fallback function? - if (!foundExternal) { - if (allowExternalFunctionFallbacks) { - fallbackFunctionContainer = ContentAtPath (new Path (funcName)) as Container; - Assert (fallbackFunctionContainer != null, "Trying to call EXTERNAL function '" + funcName + "' which has not been bound, and fallback ink function could not be found."); - - // Divert direct into fallback function and we're done - state.callStack.Push (PushPopType.Function); - state.divertedTargetObject = fallbackFunctionContainer; - return; - - } else { - Assert (false, "Trying to call EXTERNAL function '" + funcName + "' which has not been bound (and ink fallbacks disabled)."); - } - } - - // Pop arguments - var arguments = new List(); - for (int i = 0; i < numberOfArguments; ++i) { - var poppedObj = state.PopEvaluationStack () as Value; - var valueObj = poppedObj.valueObject; - arguments.Add (valueObj); - } - - // Reverse arguments from the order they were popped, - // so they're the right way round again. - arguments.Reverse (); - - // Run the function! - object funcResult = func (arguments.ToArray()); - - // Convert return value (if any) to the a type that the ink engine can use - Runtime.Object returnObj = null; - if (funcResult != null) { - returnObj = Value.Create (funcResult); - Assert (returnObj != null, "Could not create ink value from returned object of type " + funcResult.GetType()); - } else { - returnObj = new Runtime.Void (); - } - - state.PushEvaluationStack (returnObj); - } - - /// - /// General purpose delegate definition for bound EXTERNAL function definitions - /// from ink. Note that this version isn't necessary if you have a function - /// with three arguments or less - see the overloads of BindExternalFunction. - /// - public delegate object ExternalFunction(object[] args); - - /// - /// Most general form of function binding that returns an object - /// and takes an array of object parameters. - /// The only way to bind a function with more than 3 arguments. - /// - /// EXTERNAL ink function name to bind to. - /// The C# function to bind. - public void BindExternalFunctionGeneral(string funcName, ExternalFunction func) - { - Assert (!_externals.ContainsKey (funcName), "Function '" + funcName + "' has already been bound."); - _externals [funcName] = func; - } - - object TryCoerce(object value) - { - if (value == null) - return null; - - if (value is T) - return (T) value; - - if (value is float && typeof(T) == typeof(int)) { - int intVal = (int)Math.Round ((float)value); - return intVal; - } - - if (value is int && typeof(T) == typeof(float)) { - float floatVal = (float)(int)value; - return floatVal; - } - - if (value is int && typeof(T) == typeof(bool)) { - int intVal = (int)value; - return intVal == 0 ? false : true; - } - - if (typeof(T) == typeof(string)) { - return value.ToString (); - } - - Assert (false, "Failed to cast " + value.GetType ().Name + " to " + typeof(T).Name); - - return null; - } - - // Convenience overloads for standard functions and actions of various arities - // Is there a better way of doing this?! - - /// - /// Bind a C# function to an ink EXTERNAL function declaration. - /// - /// EXTERNAL ink function name to bind to. - /// The C# function to bind. - public void BindExternalFunction(string funcName, Func func) - { + if (value is int && typeof(T) == typeof(bool)) { + int intVal = (int)value; + return intVal == 0 ? false : true; + } + + if (typeof(T) == typeof(string)) { + return value.ToString (); + } + + Assert (false, "Failed to cast " + value.GetType ().Name + " to " + typeof(T).Name); + + return null; + } + + // Convenience overloads for standard functions and actions of various arities + // Is there a better way of doing this?! + + /// + /// Bind a C# function to an ink EXTERNAL function declaration. + /// + /// EXTERNAL ink function name to bind to. + /// The C# function to bind. + public void BindExternalFunction(string funcName, Func func) + { + Assert(func != null, "Can't bind a null function"); + + BindExternalFunctionGeneral (funcName, (object[] args) => { + Assert(args.Length == 0, "External function expected no arguments"); + return func(); + }); + } + + /// + /// Bind a C# Action to an ink EXTERNAL function declaration. + /// + /// EXTERNAL ink function name to bind to. + /// The C# action to bind. + public void BindExternalFunction(string funcName, Action act) + { + Assert(act != null, "Can't bind a null function"); + + BindExternalFunctionGeneral (funcName, (object[] args) => { + Assert(args.Length == 0, "External function expected no arguments"); + act(); + return null; + }); + } + + /// + /// Bind a C# function to an ink EXTERNAL function declaration. + /// + /// EXTERNAL ink function name to bind to. + /// The C# function to bind. + public void BindExternalFunction(string funcName, Func func) + { Assert(func != null, "Can't bind a null function"); - BindExternalFunctionGeneral (funcName, (object[] args) => { - Assert(args.Length == 0, "External function expected no arguments"); - return func(); - }); - } - - /// - /// Bind a C# Action to an ink EXTERNAL function declaration. - /// - /// EXTERNAL ink function name to bind to. - /// The C# action to bind. - public void BindExternalFunction(string funcName, Action act) - { + BindExternalFunctionGeneral (funcName, (object[] args) => { + Assert(args.Length == 1, "External function expected one argument"); + return func( (T)TryCoerce(args[0]) ); + }); + } + + /// + /// Bind a C# action to an ink EXTERNAL function declaration. + /// + /// EXTERNAL ink function name to bind to. + /// The C# action to bind. + public void BindExternalFunction(string funcName, Action act) + { Assert(act != null, "Can't bind a null function"); - BindExternalFunctionGeneral (funcName, (object[] args) => { - Assert(args.Length == 0, "External function expected no arguments"); - act(); - return null; - }); - } - - /// - /// Bind a C# function to an ink EXTERNAL function declaration. - /// - /// EXTERNAL ink function name to bind to. - /// The C# function to bind. - public void BindExternalFunction(string funcName, Func func) - { + BindExternalFunctionGeneral (funcName, (object[] args) => { + Assert(args.Length == 1, "External function expected one argument"); + act( (T)TryCoerce(args[0]) ); + return null; + }); + } + + + /// + /// Bind a C# function to an ink EXTERNAL function declaration. + /// + /// EXTERNAL ink function name to bind to. + /// The C# function to bind. + public void BindExternalFunction(string funcName, Func func) + { Assert(func != null, "Can't bind a null function"); - BindExternalFunctionGeneral (funcName, (object[] args) => { - Assert(args.Length == 1, "External function expected one argument"); - return func( (T)TryCoerce(args[0]) ); - }); - } - - /// - /// Bind a C# action to an ink EXTERNAL function declaration. - /// - /// EXTERNAL ink function name to bind to. - /// The C# action to bind. - public void BindExternalFunction(string funcName, Action act) - { + BindExternalFunctionGeneral (funcName, (object[] args) => { + Assert(args.Length == 2, "External function expected two arguments"); + return func( + (T1)TryCoerce(args[0]), + (T2)TryCoerce(args[1]) + ); + }); + } + + /// + /// Bind a C# action to an ink EXTERNAL function declaration. + /// + /// EXTERNAL ink function name to bind to. + /// The C# action to bind. + public void BindExternalFunction(string funcName, Action act) + { Assert(act != null, "Can't bind a null function"); - BindExternalFunctionGeneral (funcName, (object[] args) => { - Assert(args.Length == 1, "External function expected one argument"); - act( (T)TryCoerce(args[0]) ); - return null; - }); - } - - - /// - /// Bind a C# function to an ink EXTERNAL function declaration. - /// - /// EXTERNAL ink function name to bind to. - /// The C# function to bind. - public void BindExternalFunction(string funcName, Func func) - { + BindExternalFunctionGeneral (funcName, (object[] args) => { + Assert(args.Length == 2, "External function expected two arguments"); + act( + (T1)TryCoerce(args[0]), + (T2)TryCoerce(args[1]) + ); + return null; + }); + } + + /// + /// Bind a C# function to an ink EXTERNAL function declaration. + /// + /// EXTERNAL ink function name to bind to. + /// The C# function to bind. + public void BindExternalFunction(string funcName, Func func) + { Assert(func != null, "Can't bind a null function"); - BindExternalFunctionGeneral (funcName, (object[] args) => { - Assert(args.Length == 2, "External function expected two arguments"); - return func( - (T1)TryCoerce(args[0]), - (T2)TryCoerce(args[1]) - ); - }); - } - - /// - /// Bind a C# action to an ink EXTERNAL function declaration. - /// - /// EXTERNAL ink function name to bind to. - /// The C# action to bind. - public void BindExternalFunction(string funcName, Action act) - { + BindExternalFunctionGeneral (funcName, (object[] args) => { + Assert(args.Length == 3, "External function expected three arguments"); + return func( + (T1)TryCoerce(args[0]), + (T2)TryCoerce(args[1]), + (T3)TryCoerce(args[2]) + ); + }); + } + + /// + /// Bind a C# action to an ink EXTERNAL function declaration. + /// + /// EXTERNAL ink function name to bind to. + /// The C# action to bind. + public void BindExternalFunction(string funcName, Action act) + { Assert(act != null, "Can't bind a null function"); - BindExternalFunctionGeneral (funcName, (object[] args) => { - Assert(args.Length == 2, "External function expected two arguments"); - act( - (T1)TryCoerce(args[0]), - (T2)TryCoerce(args[1]) - ); - return null; - }); - } - - /// - /// Bind a C# function to an ink EXTERNAL function declaration. - /// - /// EXTERNAL ink function name to bind to. - /// The C# function to bind. - public void BindExternalFunction(string funcName, Func func) - { + BindExternalFunctionGeneral (funcName, (object[] args) => { + Assert(args.Length == 3, "External function expected three arguments"); + act( + (T1)TryCoerce(args[0]), + (T2)TryCoerce(args[1]), + (T3)TryCoerce(args[2]) + ); + return null; + }); + } + + /// + /// Bind a C# function to an ink EXTERNAL function declaration. + /// + /// EXTERNAL ink function name to bind to. + /// The C# function to bind. + public void BindExternalFunction(string funcName, Func func) + { Assert(func != null, "Can't bind a null function"); - BindExternalFunctionGeneral (funcName, (object[] args) => { - Assert(args.Length == 3, "External function expected two arguments"); - return func( - (T1)TryCoerce(args[0]), - (T2)TryCoerce(args[1]), - (T3)TryCoerce(args[2]) - ); - }); - } - - /// - /// Bind a C# action to an ink EXTERNAL function declaration. - /// - /// EXTERNAL ink function name to bind to. - /// The C# action to bind. - public void BindExternalFunction(string funcName, Action act) - { + BindExternalFunctionGeneral (funcName, (object[] args) => { + Assert(args.Length == 4, "External function expected four arguments"); + return func( + (T1)TryCoerce(args[0]), + (T2)TryCoerce(args[1]), + (T3)TryCoerce(args[2]), + (T4)TryCoerce(args[3]) + ); + }); + } + + /// + /// Bind a C# action to an ink EXTERNAL function declaration. + /// + /// EXTERNAL ink function name to bind to. + /// The C# action to bind. + public void BindExternalFunction(string funcName, Action act) + { Assert(act != null, "Can't bind a null function"); - BindExternalFunctionGeneral (funcName, (object[] args) => { - Assert(args.Length == 3, "External function expected two arguments"); - act( - (T1)TryCoerce(args[0]), - (T2)TryCoerce(args[1]), - (T3)TryCoerce(args[2]) - ); - return null; - }); - } - - /// - /// Remove a binding for a named EXTERNAL ink function. - /// - public void UnbindExternalFunction(string funcName) - { - Assert (_externals.ContainsKey (funcName), "Function '" + funcName + "' has not been bound."); - _externals.Remove (funcName); - } - - /// - /// Check that all EXTERNAL ink functions have a valid bound C# function. - /// Note that this is automatically called on the first call to Continue(). - /// - public void ValidateExternalBindings() - { + BindExternalFunctionGeneral (funcName, (object[] args) => { + Assert(args.Length == 4, "External function expected four arguments"); + act( + (T1)TryCoerce(args[0]), + (T2)TryCoerce(args[1]), + (T3)TryCoerce(args[2]), + (T4)TryCoerce(args[3]) + ); + return null; + }); + } + + /// + /// Remove a binding for a named EXTERNAL ink function. + /// + public void UnbindExternalFunction(string funcName) + { + IfAsyncWeCant ("unbind an external a function"); + Assert (_externals.ContainsKey (funcName), "Function '" + funcName + "' has not been bound."); + _externals.Remove (funcName); + } + + /// + /// Check that all EXTERNAL ink functions have a valid bound C# function. + /// Note that this is automatically called on the first call to Continue(). + /// + public void ValidateExternalBindings() + { var missingExternals = new HashSet(); ValidateExternalBindings (_mainContentContainer, missingExternals); - _hasValidatedExternals = true; + _hasValidatedExternals = true; // No problem! Validation complete if( missingExternals.Count == 0 ) { _hasValidatedExternals = true; - } + } // Error for all missing externals else { @@ -1533,36 +2028,36 @@ public void ValidateExternalBindings() string.Join("', '", missingExternals.ToArray()), allowExternalFunctionFallbacks ? ", and no fallback ink function found." : " (ink fallbacks disabled)" ); - + Error(message); } - } + } void ValidateExternalBindings(Container c, HashSet missingExternals) - { - foreach (var innerContent in c.content) { + { + foreach (var innerContent in c.content) { var container = innerContent as Container; if( container == null || !container.hasValidName ) ValidateExternalBindings (innerContent, missingExternals); - } - foreach (var innerKeyValue in c.namedContent) { + } + foreach (var innerKeyValue in c.namedContent) { ValidateExternalBindings (innerKeyValue.Value as Runtime.Object, missingExternals); - } - } + } + } void ValidateExternalBindings(Runtime.Object o, HashSet missingExternals) - { - var container = o as Container; - if (container) { - ValidateExternalBindings (container, missingExternals); - return; - } - - var divert = o as Divert; - if (divert && divert.isExternal) { - var name = divert.targetPathString; - - if (!_externals.ContainsKey (name)) { + { + var container = o as Container; + if (container) { + ValidateExternalBindings (container, missingExternals); + return; + } + + var divert = o as Divert; + if (divert && divert.isExternal) { + var name = divert.targetPathString; + + if (!_externals.ContainsKey (name)) { if( allowExternalFunctionFallbacks ) { bool fallbackFound = mainContentContainer.namedContent.ContainsKey(name); if( !fallbackFound ) { @@ -1571,485 +2066,495 @@ void ValidateExternalBindings(Runtime.Object o, HashSet missingExternals } else { missingExternals.Add(name); } - } - } - } - - /// - /// Delegate definition for variable observation - see ObserveVariable. - /// - public delegate void VariableObserver(string variableName, object newValue); - - /// - /// When the named global variable changes it's value, the observer will be - /// called to notify it of the change. Note that if the value changes multiple - /// times within the ink, the observer will only be called once, at the end - /// of the ink's evaluation. If, during the evaluation, it changes and then - /// changes back again to its original value, it will still be called. - /// Note that the observer will also be fired if the value of the variable - /// is changed externally to the ink, by directly setting a value in - /// story.variablesState. - /// - /// The name of the global variable to observe. - /// A delegate function to call when the variable changes. - public void ObserveVariable(string variableName, VariableObserver observer) - { - if (_variableObservers == null) - _variableObservers = new Dictionary (); - - if (_variableObservers.ContainsKey (variableName)) { - _variableObservers[variableName] += observer; - } else { - _variableObservers[variableName] = observer; - } - } - - /// - /// Convenience function to allow multiple variables to be observed with the same - /// observer delegate function. See the singular ObserveVariable for details. - /// The observer will get one call for every variable that has changed. - /// - /// The set of variables to observe. - /// The delegate function to call when any of the named variables change. - public void ObserveVariables(IList variableNames, VariableObserver observer) - { - foreach (var varName in variableNames) { - ObserveVariable (varName, observer); - } - } - - /// - /// Removes the variable observer, to stop getting variable change notifications. - /// If you pass a specific variable name, it will stop observing that particular one. If you - /// pass null (or leave it blank, since it's optional), then the observer will be removed - /// from all variables that it's subscribed to. - /// - /// The observer to stop observing. - /// (Optional) Specific variable name to stop observing. - public void RemoveVariableObserver(VariableObserver observer, string specificVariableName = null) - { - if (_variableObservers == null) - return; - - // Remove observer for this specific variable - if (specificVariableName != null) { - if (_variableObservers.ContainsKey (specificVariableName)) { - _variableObservers [specificVariableName] -= observer; - } - } - - // Remove observer for all variables - else { - foreach (var keyValue in _variableObservers) { - var varName = keyValue.Key; - _variableObservers [varName] -= observer; - } - } - } - - void VariableStateDidChangeEvent(string variableName, Runtime.Object newValueObj) - { - if (_variableObservers == null) - return; - - VariableObserver observers = null; - if (_variableObservers.TryGetValue (variableName, out observers)) { - - if (!(newValueObj is Value)) { - throw new System.Exception ("Tried to get the value of a variable that isn't a standard type"); - } - var val = newValueObj as Value; - - observers (variableName, val.valueObject); - } - } - - /// - /// Get any global tags associated with the story. These are defined as - /// hash tags defined at the very top of the story. - /// - public List globalTags { - get { - return TagsAtStartOfFlowContainerWithPathString (""); - } - } - - /// - /// Gets any tags associated with a particular knot or knot.stitch. - /// These are defined as hash tags defined at the very top of a - /// knot or stitch. - /// - /// The path of the knot or stitch, in the form "knot" or "knot.stitch". - public List TagsForContentAtPath (string path) - { - return TagsAtStartOfFlowContainerWithPathString (path); - } - - List TagsAtStartOfFlowContainerWithPathString (string pathString) - { - var path = new Runtime.Path (pathString); - - // Expected to be global story, knot or stitch - var flowContainer = ContentAtPath (path) as Container; - while(true) { - var firstContent = flowContainer.content [0]; - if (firstContent is Container) - flowContainer = (Container)firstContent; - else break; - } - - // Any initial tag objects count as the "main tags" associated with that story/knot/stitch - List tags = null; - foreach (var c in flowContainer.content) { - var tag = c as Runtime.Tag; - if (tag) { - if (tags == null) tags = new List (); - tags.Add (tag.text); - } else break; - } - - return tags; - } - - /// - /// 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. - /// - public virtual string BuildStringOfHierarchy() - { - var sb = new StringBuilder (); - - mainContentContainer.BuildStringOfHierarchy (sb, 0, state.currentContentObject); - - return sb.ToString (); - } - - string BuildStringOfContainer (Container container) - { - var sb = new StringBuilder (); - - container.BuildStringOfHierarchy (sb, 0, state.currentContentObject); - - return sb.ToString(); - } + } + } + } + + /// + /// Delegate definition for variable observation - see ObserveVariable. + /// + public delegate void VariableObserver(string variableName, object newValue); + + /// + /// When the named global variable changes it's value, the observer will be + /// called to notify it of the change. Note that if the value changes multiple + /// times within the ink, the observer will only be called once, at the end + /// of the ink's evaluation. If, during the evaluation, it changes and then + /// changes back again to its original value, it will still be called. + /// Note that the observer will also be fired if the value of the variable + /// is changed externally to the ink, by directly setting a value in + /// story.variablesState. + /// + /// The name of the global variable to observe. + /// A delegate function to call when the variable changes. + public void ObserveVariable(string variableName, VariableObserver observer) + { + IfAsyncWeCant ("observe a new variable"); + + if (_variableObservers == null) + _variableObservers = new Dictionary (); + + if( !state.variablesState.GlobalVariableExistsWithName(variableName) ) + throw new StoryException("Cannot observe variable '"+variableName+"' because it wasn't declared in the ink story."); + + if (_variableObservers.ContainsKey (variableName)) { + _variableObservers[variableName] += observer; + } else { + _variableObservers[variableName] = observer; + } + } + + /// + /// Convenience function to allow multiple variables to be observed with the same + /// observer delegate function. See the singular ObserveVariable for details. + /// The observer will get one call for every variable that has changed. + /// + /// The set of variables to observe. + /// The delegate function to call when any of the named variables change. + public void ObserveVariables(IList variableNames, VariableObserver observer) + { + foreach (var varName in variableNames) { + ObserveVariable (varName, observer); + } + } + + /// + /// Removes the variable observer, to stop getting variable change notifications. + /// If you pass a specific variable name, it will stop observing that particular one. If you + /// pass null (or leave it blank, since it's optional), then the observer will be removed + /// from all variables that it's subscribed to. If you pass in a specific variable name and + /// null for the the observer, all observers for that variable will be removed. + /// + /// (Optional) The observer to stop observing. + /// (Optional) Specific variable name to stop observing. + public void RemoveVariableObserver(VariableObserver observer = null, string specificVariableName = null) + { + IfAsyncWeCant ("remove a variable observer"); + + if (_variableObservers == null) + return; + + // Remove observer for this specific variable + if (specificVariableName != null) { + if (_variableObservers.ContainsKey (specificVariableName)) { + if( observer != null) { + _variableObservers [specificVariableName] -= observer; + if (_variableObservers[specificVariableName] == null) { + _variableObservers.Remove(specificVariableName); + } + } + else { + _variableObservers.Remove(specificVariableName); + } + } + } + + // Remove observer for all variables + else if( observer != null) { + var keys = new List(_variableObservers.Keys); + foreach (var varName in keys) { + _variableObservers[varName] -= observer; + if (_variableObservers[varName] == null) { + _variableObservers.Remove(varName); + } + } + } + } + + void VariableStateDidChangeEvent(string variableName, Runtime.Object newValueObj) + { + if (_variableObservers == null) + return; + + VariableObserver observers = null; + if (_variableObservers.TryGetValue (variableName, out observers)) { + + if (!(newValueObj is Value)) { + throw new System.Exception ("Tried to get the value of a variable that isn't a standard type"); + } + var val = newValueObj as Value; + + observers (variableName, val.valueObject); + } + } + + /// + /// Get any global tags associated with the story. These are defined as + /// hash tags defined at the very top of the story. + /// + public List globalTags { + get { + return TagsAtStartOfFlowContainerWithPathString (""); + } + } + + /// + /// Gets any tags associated with a particular knot or knot.stitch. + /// These are defined as hash tags defined at the very top of a + /// knot or stitch. + /// + /// The path of the knot or stitch, in the form "knot" or "knot.stitch". + public List TagsForContentAtPath (string path) + { + return TagsAtStartOfFlowContainerWithPathString (path); + } + + List TagsAtStartOfFlowContainerWithPathString (string pathString) + { + var path = new Runtime.Path (pathString); + + // Expected to be global story, knot or stitch + var flowContainer = ContentAtPath (path).container; + while(true) { + var firstContent = flowContainer.content [0]; + if (firstContent is Container) + flowContainer = (Container)firstContent; + else break; + } + + // Any initial tag objects count as the "main tags" associated with that story/knot/stitch + List tags = null; + foreach (var c in flowContainer.content) { + var tag = c as Runtime.Tag; + if (tag) { + if (tags == null) tags = new List (); + tags.Add (tag.text); + } else break; + } + + return tags; + } + + /// + /// 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. + /// + public virtual string BuildStringOfHierarchy() + { + var sb = new StringBuilder (); + + mainContentContainer.BuildStringOfHierarchy (sb, 0, state.currentPointer.Resolve()); + + return sb.ToString (); + } + + string BuildStringOfContainer (Container container) + { + var sb = new StringBuilder (); + + container.BuildStringOfHierarchy (sb, 0, state.currentPointer.Resolve()); + + return sb.ToString(); + } private void NextContent() { - // Setting previousContentObject is critical for VisitChangedContainersDueToDivert - state.previousContentObject = state.currentContentObject; + // Setting previousContentObject is critical for VisitChangedContainersDueToDivert + state.previousPointer = state.currentPointer; // Divert step? - if (state.divertedTargetObject != null) { + if (!state.divertedPointer.isNull) { + + state.currentPointer = state.divertedPointer; + state.divertedPointer = Pointer.Null; + + // Internally uses state.previousContentObject and state.currentContentObject + VisitChangedContainersDueToDivert (); + + // Diverted location has valid content? + if (!state.currentPointer.isNull) { + return; + } + + // Otherwise, if diverted location doesn't have valid content, + // drop down and attempt to increment. + // This can happen if the diverted path is intentionally jumping + // to the end of a container - e.g. a Conditional that's re-joining + } + + bool successfulPointerIncrement = IncrementContentPointer (); + + // Ran out of content? Try to auto-exit from a function, + // or finish evaluating the content of a thread + if (!successfulPointerIncrement) { + + bool didPop = false; + + if (state.callStack.CanPop (PushPopType.Function)) { + + // Pop from the call stack + state.PopCallstack (PushPopType.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 that the evaluator has something to chomp on if it needs it + if (state.inExpressionEvaluation) { + state.PushEvaluationStack (new Runtime.Void ()); + } + + didPop = true; + } else if (state.callStack.canPopThread) { + state.callStack.PopThread (); - state.currentContentObject = state.divertedTargetObject; - state.divertedTargetObject = null; + didPop = true; + } else { + state.TryExitFunctionEvaluationFromGame (); + } - // Internally uses state.previousContentObject and state.currentContentObject - VisitChangedContainersDueToDivert (); + // Step past the point where we last called out + if (didPop && !state.currentPointer.isNull) { + NextContent (); + } + } + } + + bool IncrementContentPointer() + { + bool successfulIncrement = true; + + var pointer = state.callStack.currentElement.currentPointer; + pointer.index++; + + // Each time we step off the end, we fall out to the next container, all the + // while we're in indexed rather than named content + while (pointer.index >= pointer.container.content.Count) { + + successfulIncrement = false; + + Container nextAncestor = pointer.container.parent as Container; + if (!nextAncestor) { + break; + } - // Diverted location has valid content? - if (state.currentContentObject != null) { - return; - } + var indexInAncestor = nextAncestor.content.IndexOf (pointer.container); + if (indexInAncestor == -1) { + break; + } - // Otherwise, if diverted location doesn't have valid content, - // drop down and attempt to increment. - // This can happen if the diverted path is intentionally jumping - // to the end of a container - e.g. a Conditional that's re-joining + pointer = new Pointer (nextAncestor, indexInAncestor); + + // Increment to next content in outer container + pointer.index++; + + successfulIncrement = true; } - bool successfulPointerIncrement = IncrementContentPointer (); + if (!successfulIncrement) pointer = Pointer.Null; + + state.callStack.currentElement.currentPointer = pointer; + + return successfulIncrement; + } + + bool TryFollowDefaultInvisibleChoice() + { + var allChoices = _state.currentChoices; + + // Is a default invisible choice the ONLY choice? + var invisibleChoices = allChoices.Where (c => c.isInvisibleDefault).ToList(); + if (invisibleChoices.Count == 0 || allChoices.Count > invisibleChoices.Count) + return false; + + var choice = invisibleChoices [0]; - // Ran out of content? Try to auto-exit from a function, - // or finish evaluating the content of a thread - if (!successfulPointerIncrement) { + // Invisible choice may have been generated on a different thread, + // in which case we need to restore it before we continue + state.callStack.currentThread = choice.threadAtGeneration; - bool didPop = false; + // If there's a chance that this state will be rolled back to 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 ( _stateSnapshotAtLastNewline != null ) + state.callStack.currentThread = state.callStack.ForkThread(); - if (state.callStack.CanPop (PushPopType.Function)) { + ChoosePath (choice.targetPath, incrementingTurnIndex: false); - // Pop from the call stack - state.callStack.Pop (PushPopType.Function); + return true; + } + + + // Note that this is O(n), since it re-evaluates the shuffle indices + // from a consistent seed each time. + // TODO: Is this the best algorithm it can be? + int NextSequenceShuffleIndex() + { + var numElementsIntVal = state.PopEvaluationStack () as IntValue; + if (numElementsIntVal == null) { + Error ("expected number of elements in sequence for shuffle index"); + return 0; + } - // This pop was due to dropping off the end of a function that didn't return anything, - // so in this case, we make sure that the evaluator has something to chomp on if it needs it - if (state.inExpressionEvaluation) { - state.PushEvaluationStack (new Runtime.Void ()); - } + var seqContainer = state.currentPointer.container; + + int numElements = numElementsIntVal.value; + + var seqCountVal = state.PopEvaluationStack () as IntValue; + var seqCount = seqCountVal.value; + var loopIndex = seqCount / numElements; + var iterationIndex = seqCount % numElements; + + // Generate the same shuffle based on: + // - The hash of this container, to make sure it's consistent + // each time the runtime returns to the sequence + // - How many times the runtime has looped around this full shuffle + var seqPathStr = seqContainer.path.ToString(); + int sequenceHash = 0; + foreach (char c in seqPathStr) { + sequenceHash += c; + } + var randomSeed = sequenceHash + loopIndex + state.storySeed; + var random = new Random (randomSeed); - didPop = true; - } else if (state.callStack.canPopThread) { - state.callStack.PopThread (); + var unpickedIndices = new List (); + for (int i = 0; i < numElements; ++i) { + unpickedIndices.Add (i); + } - didPop = true; - } else { - state.TryExitExternalFunctionEvaluation (); - } + for (int i = 0; i <= iterationIndex; ++i) { + var chosen = random.Next () % unpickedIndices.Count; + var chosenIndex = unpickedIndices [chosen]; + unpickedIndices.RemoveAt (chosen); - // Step past the point where we last called out - if (didPop && state.currentContentObject != null) { - NextContent (); - } + if (i == iterationIndex) { + return chosenIndex; + } } + + throw new System.Exception ("Should never reach here"); + } + + // Throw an exception that gets caught and causes AddError to be called, + // then exits the flow. + internal void Error(string message, bool useEndLineNumber = false) + { + var e = new StoryException (message); + e.useEndLineNumber = useEndLineNumber; + throw e; } - bool IncrementContentPointer() - { - bool successfulIncrement = true; - - var currEl = state.callStack.currentElement; - currEl.currentContentIndex++; + internal void Warning (string message) + { + AddError (message, isWarning:true); + } - // Each time we step off the end, we fall out to the next container, all the - // while we're in indexed rather than named content - while (currEl.currentContentIndex >= currEl.currentContainer.content.Count) { + void AddError (string message, bool isWarning = false, bool useEndLineNumber = false) + { + var dm = currentDebugMetadata; - successfulIncrement = false; + var errorTypeStr = isWarning ? "WARNING" : "ERROR"; - Container nextAncestor = currEl.currentContainer.parent as Container; - if (!nextAncestor) { - break; - } - - var indexInAncestor = nextAncestor.content.IndexOf (currEl.currentContainer); - if (indexInAncestor == -1) { - break; - } - - currEl.currentContainer = nextAncestor; - currEl.currentContentIndex = indexInAncestor + 1; - - successfulIncrement = true; - } - - if (!successfulIncrement) - currEl.currentContainer = null; - - return successfulIncrement; - } - - bool TryFollowDefaultInvisibleChoice() - { - var allChoices = _state.currentChoices; - - // Is a default invisible choice the ONLY choice? - var invisibleChoices = allChoices.Where (c => c.choicePoint.isInvisibleDefault).ToList(); - if (invisibleChoices.Count == 0 || allChoices.Count > invisibleChoices.Count) - return false; - - var choice = invisibleChoices [0]; - - ChoosePath (choice.choicePoint.choiceTarget.path); - - return true; - } - - int VisitCountForContainer(Container container) - { - if( !container.visitsShouldBeCounted ) { - Error ("Read count for target ("+container.name+" - on "+container.debugMetadata+") unknown. The story may need to be compiled with countAllVisits flag (-c)."); - return 0; - } - - int count = 0; - var containerPathStr = container.path.ToString(); - state.visitCounts.TryGetValue (containerPathStr, out count); - return count; - } - - void IncrementVisitCountForContainer(Container container) - { - int count = 0; - var containerPathStr = container.path.ToString(); - state.visitCounts.TryGetValue (containerPathStr, out count); - count++; - state.visitCounts [containerPathStr] = count; - } - - void RecordTurnIndexVisitToContainer(Container container) - { - var containerPathStr = container.path.ToString(); - state.turnIndices [containerPathStr] = state.currentTurnIndex; - } - - int TurnsSinceForContainer(Container container) - { - if( !container.turnIndexShouldBeCounted ) { - Error ("TURNS_SINCE() for target ("+container.name+" - on "+container.debugMetadata+") unknown. The story may need to be compiled with countAllVisits flag (-c)."); - } - - int index = 0; - var containerPathStr = container.path.ToString(); - if (state.turnIndices.TryGetValue (containerPathStr, out index)) { - return state.currentTurnIndex - index; - } else { - return -1; - } - } - - // Note that this is O(n), since it re-evaluates the shuffle indices - // from a consistent seed each time. - // TODO: Is this the best algorithm it can be? - int NextSequenceShuffleIndex() - { - var numElementsIntVal = state.PopEvaluationStack () as IntValue; - if (numElementsIntVal == null) { - Error ("expected number of elements in sequence for shuffle index"); - return 0; - } - - var seqContainer = state.currentContainer; - - int numElements = numElementsIntVal.value; - - var seqCountVal = state.PopEvaluationStack () as IntValue; - var seqCount = seqCountVal.value; - var loopIndex = seqCount / numElements; - var iterationIndex = seqCount % numElements; - - // Generate the same shuffle based on: - // - The hash of this container, to make sure it's consistent - // each time the runtime returns to the sequence - // - How many times the runtime has looped around this full shuffle - var seqPathStr = seqContainer.path.ToString(); - int sequenceHash = 0; - foreach (char c in seqPathStr) { - sequenceHash += c; - } - var randomSeed = sequenceHash + loopIndex + state.storySeed; - var random = new Random (randomSeed); - - var unpickedIndices = new List (); - for (int i = 0; i < numElements; ++i) { - unpickedIndices.Add (i); - } - - for (int i = 0; i <= iterationIndex; ++i) { - var chosen = random.Next () % unpickedIndices.Count; - var chosenIndex = unpickedIndices [chosen]; - unpickedIndices.RemoveAt (chosen); - - if (i == iterationIndex) { - return chosenIndex; - } - } - - throw new System.Exception ("Should never reach here"); - } - - // Throw an exception that gets caught and causes AddError to be called, - // then exits the flow. - void Error(string message, bool useEndLineNumber = false) - { - var e = new StoryException (message); - e.useEndLineNumber = useEndLineNumber; - throw e; - } - - void AddError (string message, bool useEndLineNumber) - { - var dm = currentDebugMetadata; - - if (dm != null) { - int lineNum = useEndLineNumber ? dm.endLineNumber : dm.startLineNumber; - message = string.Format ("RUNTIME ERROR: '{0}' line {1}: {2}", dm.fileName, lineNum, message); - } else if( state.currentPath != null ) { - message = string.Format ("RUNTIME ERROR: ({0}): {1}", state.currentPath, message); + if (dm != null) { + int lineNum = useEndLineNumber ? dm.endLineNumber : dm.startLineNumber; + message = string.Format ("RUNTIME {0}: '{1}' line {2}: {3}", errorTypeStr, dm.fileName, lineNum, message); + } else if( !state.currentPointer.isNull ) { + message = string.Format ("RUNTIME {0}: ({1}): {2}", errorTypeStr, state.currentPointer.path, message); } else { - message = "RUNTIME ERROR: " + message; - } - - state.AddError (message); - - // In a broken state don't need to know about any other errors. - state.ForceEnd (); - } - - void Assert(bool condition, string message = null, params object[] formatParams) - { - if (condition == false) { - if (message == null) { - message = "Story assert"; - } - if (formatParams != null && formatParams.Count() > 0) { - message = string.Format (message, formatParams); - } - - throw new System.Exception (message + " " + currentDebugMetadata); - } - } - - DebugMetadata currentDebugMetadata - { - get { - DebugMetadata dm; - - // Try to get from the current path first - var currentContent = state.currentContentObject; - if (currentContent) { - dm = currentContent.debugMetadata; - if (dm != null) { - return dm; - } - } - - // Move up callstack if possible - for (int i = state.callStack.elements.Count - 1; i >= 0; --i) { - var currentObj = state.callStack.elements [i].currentObject; - if (currentObj && currentObj.debugMetadata != null) { - return currentObj.debugMetadata; - } - } - - // Current/previous path may not be valid if we've 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 - for (int i = state.outputStream.Count - 1; i >= 0; --i) { - var outputObj = state.outputStream [i]; - dm = outputObj.debugMetadata; - if (dm != null) { - return dm; - } - } - - return null; - } - } - - int currentLineNumber - { - get { - var dm = currentDebugMetadata; - if (dm != null) { - return dm.startLineNumber; - } - return 0; - } - } - - internal Container mainContentContainer { - get { - if (_temporaryEvaluationContainer) { - return _temporaryEvaluationContainer; - } else { - return _mainContentContainer; - } - } - } - - Container _mainContentContainer; - ListDefinitionsOrigin _listDefinitions; - - Dictionary _externals; - Dictionary _variableObservers; - bool _hasValidatedExternals; - - Container _temporaryEvaluationContainer; - - StoryState _state; + message = "RUNTIME "+errorTypeStr+": " + message; + } + + state.AddError (message, isWarning); + + // In a broken state don't need to know about any other errors. + if( !isWarning ) + state.ForceEnd (); + } + + void Assert(bool condition, string message = null, params object[] formatParams) + { + if (condition == false) { + if (message == null) { + message = "Story assert"; + } + if (formatParams != null && formatParams.Count() > 0) { + message = string.Format (message, formatParams); + } + + throw new System.Exception (message + " " + currentDebugMetadata); + } + } + + DebugMetadata currentDebugMetadata + { + get { + DebugMetadata dm; + + // Try to get from the current path first + var pointer = state.currentPointer; + if (!pointer.isNull) { + dm = pointer.Resolve().debugMetadata; + if (dm != null) { + return dm; + } + } + + // Move up callstack if possible + for (int i = state.callStack.elements.Count - 1; i >= 0; --i) { + pointer = state.callStack.elements [i].currentPointer; + if (!pointer.isNull && pointer.Resolve() != null) { + dm = pointer.Resolve().debugMetadata; + if (dm != null) { + return dm; + } + } + } + + // Current/previous path may not be valid if we've 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 + for (int i = state.outputStream.Count - 1; i >= 0; --i) { + var outputObj = state.outputStream [i]; + dm = outputObj.debugMetadata; + if (dm != null) { + return dm; + } + } + + return null; + } + } + + int currentLineNumber + { + get { + var dm = currentDebugMetadata; + if (dm != null) { + return dm.startLineNumber; + } + return 0; + } + } + + internal Container mainContentContainer { + get { + if (_temporaryEvaluationContainer) { + return _temporaryEvaluationContainer; + } else { + return _mainContentContainer; + } + } + } + + Container _mainContentContainer; + ListDefinitionsOrigin _listDefinitions; + + Dictionary _externals; + Dictionary _variableObservers; + bool _hasValidatedExternals; + + Container _temporaryEvaluationContainer; + + StoryState _state; + + bool _asyncContinueActive; + StoryState _stateSnapshotAtLastNewline = null; + + int _recursiveContinueCount = 0; + + bool _asyncSaving; + + Profiler _profiler; } } diff --git a/ink-engine-runtime/StoryState.cs b/ink-engine-runtime/StoryState.cs index fc0aa2d..7b28709 100644 --- a/ink-engine-runtime/StoryState.cs +++ b/ink-engine-runtime/StoryState.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Text; using System.Diagnostics; +using System.IO; namespace Ink.Runtime { @@ -17,15 +18,26 @@ public class StoryState /// /// The current version of the state save file JSON-based format. /// - public const int kInkSaveStateVersion = 7; - const int kMinCompatibleLoadVersion = 6; + public const int kInkSaveStateVersion = 8; + const int kMinCompatibleLoadVersion = 8; /// /// Exports the current state to json format, in order to save the game. /// /// The save state in json format. public string ToJson() { - return SimpleJson.DictionaryToText (jsonToken); + var writer = new SimpleJson.Writer(); + WriteJson(writer); + return writer.ToString(); + } + + /// + /// Exports the current state to json format, in order to save the game. + /// For this overload you can pass in a custom stream, such as a FileStream. + /// + public void ToJson(Stream stream) { + var writer = new SimpleJson.Writer(stream); + WriteJson(writer); } /// @@ -34,7 +46,8 @@ public string ToJson() { /// The JSON string to load. public void LoadJson(string json) { - jsonToken = SimpleJson.TextToDictionary (json); + var jObject = SimpleJson.TextToDictionary (json); + LoadJsonObj(jObject); } /// @@ -52,13 +65,91 @@ public void LoadJson(string json) public int VisitCountAtPathString(string pathString) { int visitCountOut; - if (visitCounts.TryGetValue (pathString, out visitCountOut)) + + if ( _patch != null ) { + var container = story.ContentAtPath(new Path(pathString)).container; + if (container == null) + throw new Exception("Content at path not found: " + pathString); + + if( _patch.TryGetVisitCount(container, out visitCountOut) ) + return visitCountOut; + } + + if (_visitCounts.TryGetValue(pathString, out visitCountOut)) return visitCountOut; return 0; } - internal int callstackDepth { + internal int VisitCountForContainer(Container container) + { + if (!container.visitsShouldBeCounted) + { + story.Error("Read count for target (" + container.name + " - on " + container.debugMetadata + ") unknown."); + return 0; + } + + int count = 0; + if (_patch != null && _patch.TryGetVisitCount(container, out count)) + return count; + + var containerPathStr = container.path.ToString(); + _visitCounts.TryGetValue(containerPathStr, out count); + return count; + } + + internal void IncrementVisitCountForContainer(Container container) + { + if( _patch != null ) { + var currCount = VisitCountForContainer(container); + currCount++; + _patch.SetVisitCount(container, currCount); + return; + } + + int count = 0; + var containerPathStr = container.path.ToString(); + _visitCounts.TryGetValue(containerPathStr, out count); + count++; + _visitCounts[containerPathStr] = count; + } + + internal void RecordTurnIndexVisitToContainer(Container container) + { + if( _patch != null ) { + _patch.SetTurnIndex(container, currentTurnIndex); + return; + } + + var containerPathStr = container.path.ToString(); + _turnIndices[containerPathStr] = currentTurnIndex; + } + + internal int TurnsSinceForContainer(Container container) + { + if (!container.turnIndexShouldBeCounted) + { + story.Error("TURNS_SINCE() for target (" + container.name + " - on " + container.debugMetadata + ") unknown."); + } + + int index = 0; + + if ( _patch != null && _patch.TryGetTurnIndex(container, out index) ) { + return currentTurnIndex - index; + } + + var containerPathStr = container.path.ToString(); + if (_turnIndices.TryGetValue(containerPathStr, out index)) + { + return currentTurnIndex - index; + } + else + { + return -1; + } + } + + internal int callstackDepth { get { return callStack.depth; } @@ -84,12 +175,13 @@ internal List generatedChoices { } } internal List currentErrors { get; private set; } + internal List currentWarnings { get; private set; } internal VariablesState variablesState { get; private set; } internal CallStack callStack { get; set; } internal List evaluationStack { get; private set; } - internal Runtime.Object divertedTargetObject { get; set; } - internal Dictionary visitCounts { get; private set; } - internal Dictionary turnIndices { get; private set; } + internal Pointer divertedPointer { get; set; } + + internal int currentTurnIndex { get; private set; } internal int storySeed { get; set; } internal int previousRandom { get; set; } @@ -97,48 +189,40 @@ internal List generatedChoices { internal Story story { get; set; } - internal Path currentPath { - get { - if (currentContentObject == null) + /// + /// String representation of the location where the story currently is. + /// + public string currentPathString { + get { + var pointer = currentPointer; + if (pointer.isNull) return null; - - return currentContentObject.path; - } - set { - if (value != null) - currentContentObject = story.ContentAtPath (value); else - currentContentObject = null; + return pointer.path.ToString(); } } - internal Runtime.Object currentContentObject { + internal Runtime.Pointer currentPointer { get { - return callStack.currentElement.currentObject; + return callStack.currentElement.currentPointer; } set { - callStack.currentElement.currentObject = value; + callStack.currentElement.currentPointer = value; } } - internal Container currentContainer { + internal Pointer previousPointer { get { - return callStack.currentElement.currentContainer; - } - } - - internal Runtime.Object previousContentObject { - get { - return callStack.currentThread.previousContentObject; + return callStack.currentThread.previousPointer; } set { - callStack.currentThread.previousContentObject = value; + callStack.currentThread.previousPointer = value; } } internal bool canContinue { get { - return currentContentObject != null && !hasError; + return !currentPointer.isNull && !hasError; } } @@ -149,6 +233,12 @@ internal bool hasError } } + internal bool hasWarning { + get { + return currentWarnings != null && currentWarnings.Count > 0; + } + } + internal string currentText { get @@ -163,7 +253,7 @@ internal string currentText } } - _currentText = sb.ToString (); + _currentText = CleanOutputWhitespace (sb.ToString ()); _outputStreamTextDirty = false; } @@ -173,6 +263,41 @@ internal string currentText } string _currentText; + // Cleans inline whitespace in the following way: + // - Removes all whitespace from the start and end of line (including just before a \n) + // - Turns all consecutive space and tab runs into single spaces (HTML style) + string CleanOutputWhitespace(string str) + { + var sb = new StringBuilder(str.Length); + + int currentWhitespaceStart = -1; + int startOfLine = 0; + + for (int i = 0; i < str.Length; i++) { + var c = str[i]; + + bool isInlineWhitespace = c == ' ' || c == '\t'; + + if (isInlineWhitespace && currentWhitespaceStart == -1) + currentWhitespaceStart = i; + + if (!isInlineWhitespace) { + if (c != '\n' && currentWhitespaceStart > 0 && currentWhitespaceStart != startOfLine) { + sb.Append(' '); + } + currentWhitespaceStart = -1; + } + + if (c == '\n') + startOfLine = i + 1; + + if (!isInlineWhitespace) + sb.Append(c); + } + + return sb.ToString(); + } + internal List currentTags { get @@ -213,11 +338,12 @@ internal StoryState (Story story) evaluationStack = new List (); - callStack = new CallStack (story.rootContentContainer); + callStack = new CallStack (story); variablesState = new VariablesState (callStack, story.listDefinitions); - visitCounts = new Dictionary (); - turnIndices = new Dictionary (); + _visitCounts = new Dictionary (); + _turnIndices = new Dictionary (); + currentTurnIndex = -1; // Seed the shuffle random numbers @@ -232,8 +358,7 @@ internal StoryState (Story story) internal void GoToStart() { - callStack.currentElement.currentContainer = story.mainContentContainer; - callStack.currentElement.currentContentIndex = 0; + callStack.currentElement.currentPointer = Pointer.StartOf (story.mainContentContainer); } // Warning: Any Runtime.Object content referenced within the StoryState will @@ -241,12 +366,14 @@ internal void GoToStart() // Runtime.Objects are treated as immutable after they've been set up. // (e.g. we don't edit a Runtime.StringValue after it's been created an added.) // I wonder if there's a sensible way to enforce that..?? - internal StoryState Copy() + internal StoryState CopyAndStartPatching() { var copy = new StoryState(story); + copy._patch = new StatePatch(_patch); + copy.outputStream.AddRange(_outputStream); - OutputStreamDirty(); + copy.OutputStreamDirty(); copy._currentChoices.AddRange(_currentChoices); @@ -254,24 +381,32 @@ internal StoryState Copy() copy.currentErrors = new List (); copy.currentErrors.AddRange (currentErrors); } + if (hasWarning) { + copy.currentWarnings = new List (); + copy.currentWarnings.AddRange (currentWarnings); + } copy.callStack = new CallStack (callStack); - if( _originalCallstack != null ) - copy._originalCallstack = new CallStack(_originalCallstack); - copy.variablesState = new VariablesState (copy.callStack, story.listDefinitions); - copy.variablesState.CopyFrom (variablesState); + // ref copy - exactly the same variables state! + // we're expecting not to read it only while in patch mode + // (though the callstack will be modified) + copy.variablesState = variablesState; + copy.variablesState.callStack = copy.callStack; + copy.variablesState.patch = copy._patch; copy.evaluationStack.AddRange (evaluationStack); - copy._originalEvaluationStackHeight = _originalEvaluationStackHeight; - if (divertedTargetObject != null) - copy.divertedTargetObject = divertedTargetObject; + if (!divertedPointer.isNull) + copy.divertedPointer = divertedPointer; + + copy.previousPointer = previousPointer; - copy.previousContentObject = previousContentObject; + // visit counts and turn indicies will be read only, not modified + // while in patch mode + copy._visitCounts = _visitCounts; + copy._turnIndices = _turnIndices; - copy.visitCounts = new Dictionary (visitCounts); - copy.turnIndices = new Dictionary (turnIndices); copy.currentTurnIndex = currentTurnIndex; copy.storySeed = storySeed; copy.previousRandom = previousRandom; @@ -280,121 +415,169 @@ internal StoryState Copy() return copy; } - - /// - /// Object representation of full JSON state. Usually you should use - /// LoadJson and ToJson since they serialise directly to string for you. - /// But it may be useful to get the object representation so that you - /// can integrate it into your own serialisation system. - /// - public Dictionary jsonToken + + internal void RestoreAfterPatch() { - get { - - var obj = new Dictionary (); + // VariablesState was being borrowed by the patched + // state, so restore it with our own callstack. + // _patch will be null normally, but if you're in the + // middle of a save, it may contain a _patch for save purpsoes. + variablesState.callStack = callStack; + variablesState.patch = _patch; // usually null + } - Dictionary choiceThreads = null; - foreach (Choice c in _currentChoices) { - c.originalChoicePath = c.choicePoint.path.componentsString; - c.originalThreadIndex = c.threadAtGeneration.threadIndex; + internal void ApplyAnyPatch() + { + if (_patch == null) return; - if( callStack.ThreadWithIndex(c.originalThreadIndex) == null ) { - if( choiceThreads == null ) - choiceThreads = new Dictionary (); + variablesState.ApplyPatch(); - choiceThreads[c.originalThreadIndex.ToString()] = c.threadAtGeneration.jsonToken; - } + foreach(var pathToCount in _patch.visitCounts) + ApplyCountChanges(pathToCount.Key, pathToCount.Value, isVisit:true); + + foreach (var pathToIndex in _patch.turnIndices) + ApplyCountChanges(pathToIndex.Key, pathToIndex.Value, isVisit:false); + + _patch = null; + } + + void ApplyCountChanges(Container container, int newCount, bool isVisit) + { + var counts = isVisit ? _visitCounts : _turnIndices; + counts[container.path.ToString()] = newCount; + } + + void WriteJson(SimpleJson.Writer writer) + { + writer.WriteObjectStart(); + + + bool hasChoiceThreads = false; + foreach (Choice c in _currentChoices) + { + c.originalThreadIndex = c.threadAtGeneration.threadIndex; + + if (callStack.ThreadWithIndex(c.originalThreadIndex) == null) + { + if (!hasChoiceThreads) + { + hasChoiceThreads = true; + writer.WritePropertyStart("choiceThreads"); + writer.WriteObjectStart(); + } + + writer.WritePropertyStart(c.originalThreadIndex); + c.threadAtGeneration.WriteJson(writer); + writer.WritePropertyEnd(); } - if( choiceThreads != null ) - obj["choiceThreads"] = choiceThreads; + } - - obj ["callstackThreads"] = callStack.GetJsonToken(); - obj ["variablesState"] = variablesState.jsonToken; + if (hasChoiceThreads) + { + writer.WriteObjectEnd(); + writer.WritePropertyEnd(); + } - obj ["evalStack"] = Json.ListToJArray (evaluationStack); + writer.WriteProperty("callstackThreads", callStack.WriteJson); - obj ["outputStream"] = Json.ListToJArray (_outputStream); + writer.WriteProperty("variablesState", variablesState.WriteJson); - obj ["currentChoices"] = Json.ListToJArray (_currentChoices); + writer.WriteProperty("evalStack", w => Json.WriteListRuntimeObjs(w, evaluationStack)); - if( divertedTargetObject != null ) - obj ["currentDivertTarget"] = divertedTargetObject.path.componentsString; + writer.WriteProperty("outputStream", w => Json.WriteListRuntimeObjs(w, _outputStream)); - obj ["visitCounts"] = Json.IntDictionaryToJObject (visitCounts); - obj ["turnIndices"] = Json.IntDictionaryToJObject (turnIndices); - obj ["turnIdx"] = currentTurnIndex; - obj ["storySeed"] = storySeed; - obj ["previousRandom"] = previousRandom; + writer.WriteProperty("currentChoices", w => { + w.WriteArrayStart(); + foreach (var c in _currentChoices) + Json.WriteChoice(w, c); + w.WriteArrayEnd(); + }); - obj ["inkSaveVersion"] = kInkSaveStateVersion; + if (!divertedPointer.isNull) + writer.WriteProperty("currentDivertTarget", divertedPointer.path.componentsString); + + writer.WriteProperty("visitCounts", w => Json.WriteIntDictionary(w, _visitCounts)); + writer.WriteProperty("turnIndices", w => Json.WriteIntDictionary(w, _turnIndices)); - // Not using this right now, but could do in future. - obj ["inkFormatVersion"] = Story.inkVersionCurrent; - return obj; - } - set { + writer.WriteProperty("turnIdx", currentTurnIndex); + writer.WriteProperty("storySeed", storySeed); + writer.WriteProperty("previousRandom", previousRandom); - var jObject = value; + writer.WriteProperty("inkSaveVersion", kInkSaveStateVersion); - object jSaveVersion = null; - if (!jObject.TryGetValue("inkSaveVersion", out jSaveVersion)) { - throw new StoryException ("ink save format incorrect, can't load."); - } - else if ((int)jSaveVersion < kMinCompatibleLoadVersion) { - throw new StoryException("Ink save format isn't compatible with the current version (saw '"+jSaveVersion+"', but minimum is "+kMinCompatibleLoadVersion+"), so can't load."); - } + // Not using this right now, but could do in future. + writer.WriteProperty("inkFormatVersion", Story.inkVersionCurrent); + + writer.WriteObjectEnd(); + } - callStack.SetJsonToken ((Dictionary < string, object > )jObject ["callstackThreads"], story); - variablesState.jsonToken = (Dictionary < string, object> )jObject["variablesState"]; - evaluationStack = Json.JArrayToRuntimeObjList ((List)jObject ["evalStack"]); + void LoadJsonObj(Dictionary jObject) + { + object jSaveVersion = null; + if (!jObject.TryGetValue("inkSaveVersion", out jSaveVersion)) { + throw new StoryException ("ink save format incorrect, can't load."); + } + else if ((int)jSaveVersion < kMinCompatibleLoadVersion) { + throw new StoryException("Ink save format isn't compatible with the current version (saw '"+jSaveVersion+"', but minimum is "+kMinCompatibleLoadVersion+"), so can't load."); + } - _outputStream = Json.JArrayToRuntimeObjList ((List)jObject ["outputStream"]); - OutputStreamDirty(); + callStack.SetJsonToken ((Dictionary < string, object > )jObject ["callstackThreads"], story); + variablesState.SetJsonToken((Dictionary < string, object> )jObject["variablesState"]); - _currentChoices = Json.JArrayToRuntimeObjList((List)jObject ["currentChoices"]); + evaluationStack = Json.JArrayToRuntimeObjList ((List)jObject ["evalStack"]); - object currentDivertTargetPath; - if (jObject.TryGetValue("currentDivertTarget", out currentDivertTargetPath)) { - var divertPath = new Path (currentDivertTargetPath.ToString ()); - divertedTargetObject = story.ContentAtPath (divertPath); - } - - visitCounts = Json.JObjectToIntDictionary ((Dictionary)jObject ["visitCounts"]); - turnIndices = Json.JObjectToIntDictionary ((Dictionary)jObject ["turnIndices"]); - currentTurnIndex = (int)jObject ["turnIdx"]; - storySeed = (int)jObject ["storySeed"]; - previousRandom = (int)jObject ["previousRandom"]; - - object jChoiceThreadsObj = null; - jObject.TryGetValue("choiceThreads", out jChoiceThreadsObj); - var jChoiceThreads = (Dictionary)jChoiceThreadsObj; - - foreach (var c in _currentChoices) { - c.choicePoint = (ChoicePoint) story.ContentAtPath (new Path (c.originalChoicePath)); - - var foundActiveThread = callStack.ThreadWithIndex(c.originalThreadIndex); - if( foundActiveThread != null ) { - c.threadAtGeneration = foundActiveThread; - } else { - var jSavedChoiceThread = (Dictionary ) jChoiceThreads[c.originalThreadIndex.ToString()]; - c.threadAtGeneration = new CallStack.Thread(jSavedChoiceThread, story); - } - } + _outputStream = Json.JArrayToRuntimeObjList ((List)jObject ["outputStream"]); + OutputStreamDirty(); + _currentChoices = Json.JArrayToRuntimeObjList((List)jObject ["currentChoices"]); + + object currentDivertTargetPath; + if (jObject.TryGetValue("currentDivertTarget", out currentDivertTargetPath)) { + var divertPath = new Path (currentDivertTargetPath.ToString ()); + divertedPointer = story.PointerAtPath (divertPath); + } + + _visitCounts = Json.JObjectToIntDictionary((Dictionary)jObject["visitCounts"]); + _turnIndices = Json.JObjectToIntDictionary((Dictionary)jObject["turnIndices"]); + + currentTurnIndex = (int)jObject ["turnIdx"]; + storySeed = (int)jObject ["storySeed"]; + + // Not optional, but bug in inkjs means it's actually missing in inkjs saves + object previousRandomObj = null; + if( jObject.TryGetValue("previousRandom", out previousRandomObj) ) { + previousRandom = (int)previousRandomObj; + } else { + previousRandom = 0; } + + object jChoiceThreadsObj = null; + jObject.TryGetValue("choiceThreads", out jChoiceThreadsObj); + var jChoiceThreads = (Dictionary)jChoiceThreadsObj; + + foreach (var c in _currentChoices) { + var foundActiveThread = callStack.ThreadWithIndex(c.originalThreadIndex); + if( foundActiveThread != null ) { + c.threadAtGeneration = foundActiveThread.Copy (); + } else { + var jSavedChoiceThread = (Dictionary ) jChoiceThreads[c.originalThreadIndex.ToString()]; + c.threadAtGeneration = new CallStack.Thread(jSavedChoiceThread, story); + } + } } internal void ResetErrors() { currentErrors = null; + currentWarnings = null; } - internal void ResetOutput() + internal void ResetOutput(List objs = null) { _outputStream.Clear (); + if( objs != null ) _outputStream.AddRange (objs); OutputStreamDirty(); } @@ -409,6 +592,7 @@ internal void PushToOutputStream(Runtime.Object obj) foreach (var textObj in listText) { PushToOutputStreamIndividual (textObj); } + OutputStreamDirty(); return; } } @@ -418,6 +602,12 @@ internal void PushToOutputStream(Runtime.Object obj) OutputStreamDirty(); } + internal void PopFromOutputStream (int count) + { + outputStream.RemoveRange (outputStream.Count - count, count); + OutputStreamDirty (); + } + // At both the start and the end of the string, split out the new lines like so: // @@ -429,14 +619,14 @@ internal void PushToOutputStream(Runtime.Object obj) // 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 an list for consistency. + // - A newline on its own is returned in a list for consistency. List TrySplittingHeadTailWhitespace(Runtime.StringValue single) { string str = single.value; int headFirstNewlineIdx = -1; int headLastNewlineIdx = -1; - for (int i = 0; i < str.Length; ++i) { + for (int i = 0; i < str.Length; i++) { char c = str [i]; if (c == '\n') { if (headFirstNewlineIdx == -1) @@ -451,7 +641,7 @@ internal void PushToOutputStream(Runtime.Object obj) int tailLastNewlineIdx = -1; int tailFirstNewlineIdx = -1; - for (int i = 0; i < str.Length; ++i) { + for (int i = str.Length-1; i >= 0; i--) { char c = str [i]; if (c == '\n') { if (tailLastNewlineIdx == -1) @@ -509,40 +699,94 @@ void PushToOutputStreamIndividual(Runtime.Object obj) bool includeInOutput = true; + // New glue, so chomp away any whitespace from the end of the stream if (glue) { + TrimNewlinesFromOutputStream(); + includeInOutput = true; + } - // Found matching left-glue for right-glue? Close it. - Glue matchingRightGlue = null; - if (glue.isLeft) - matchingRightGlue = MatchRightGlueForLeftGlue (glue); + // 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 non-whitespace. + else if( text ) { - // Left/Right glue is auto-generated for inline expressions - // where we want to absorb newlines but only in a certain direction. - // "Bi" glue is written by the user in their ink with <> - if (glue.isLeft || glue.isBi) { - TrimNewlinesFromOutputStream(matchingRightGlue); + // Where does the current function call begin? + var functionTrimIndex = -1; + var currEl = callStack.currentElement; + if (currEl.type == PushPopType.Function) { + functionTrimIndex = currEl.functionStartInOuputStream; } - includeInOutput = glue.isBi || glue.isRight; - } + // Do 2 things: + // - Find latest glue + // - Check whether we're in the middle of string evaluation + // If we're in string eval within the current function, we + // don't want to trim back further than the length of the current string. + int glueTrimIndex = -1; + for (int i = _outputStream.Count - 1; i >= 0; i--) { + var o = _outputStream [i]; + var c = o as ControlCommand; + var g = o as Glue; - else if( text ) { + // Find latest glue + if (g) { + glueTrimIndex = i; + break; + } + + // Don't function-trim past the start of a string evaluation section + else if (c && c.commandType == ControlCommand.CommandType.BeginString) { + if (i >= functionTrimIndex) { + functionTrimIndex = -1; + } + break; + } + } + + // Where is the most agressive (earliest) trim point? + var trimIndex = -1; + if (glueTrimIndex != -1 && functionTrimIndex != -1) + trimIndex = Math.Min (functionTrimIndex, glueTrimIndex); + else if (glueTrimIndex != -1) + trimIndex = glueTrimIndex; + else + trimIndex = functionTrimIndex; - if (currentGlueIndex != -1) { + // So, are we trimming then? + if (trimIndex != -1) { - // Absorb any new newlines if there's existing glue - // in the output stream. - // Also trim any extra whitespace (spaces/tabs) if so. + // While trimming, we want to throw all newlines away, + // whether due to glue or the start of a function if (text.isNewline) { - TrimFromExistingGlue (); includeInOutput = false; } - // Able to completely reset when + // Able to completely reset when normal text is pushed else if (text.isNonWhitespace) { - RemoveExistingGlue (); + + if( glueTrimIndex > -1 ) + RemoveExistingGlue (); + + // Tell all functions in callstack that we have seen proper text, + // so trimming whitespace at the start is done. + if (functionTrimIndex > -1) { + var callstackElements = callStack.elements; + for (int i = callstackElements.Count - 1; i >= 0; i--) { + var el = callstackElements [i]; + if (el.type == PushPopType.Function) { + el.functionStartInOuputStream = -1; + } else { + break; + } + } + } } - } else if (text.isNewline) { + } + + // De-duplicate newlines, and don't ever lead with a newline + else if (text.isNewline) { if (outputStreamEndsInNewline || !outputStreamContainsContent) includeInOutput = false; } @@ -550,36 +794,30 @@ void PushToOutputStreamIndividual(Runtime.Object obj) if (includeInOutput) { _outputStream.Add (obj); + OutputStreamDirty(); } - - OutputStreamDirty(); } - void TrimNewlinesFromOutputStream(Glue rightGlueToStopAt) + void TrimNewlinesFromOutputStream() { int removeWhitespaceFrom = -1; - int rightGluePos = -1; - bool foundNonWhitespace = false; // Work back from the end, and try to find the point where - // we need to start removing content. There are two ways: - // - Start from the matching right-glue (because we just saw a left-glue) + // 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 int i = _outputStream.Count-1; while (i >= 0) { var obj = _outputStream [i]; var cmd = obj as ControlCommand; var txt = obj as StringValue; - var glue = obj as Glue; if (cmd || (txt && txt.isNonWhitespace)) { - foundNonWhitespace = true; - if( rightGlueToStopAt == null ) - break; - } else if (rightGlueToStopAt && glue == rightGlueToStopAt) { - rightGluePos = i; break; - } else if (txt && txt.isNewline && !foundNonWhitespace) { + } + else if (txt && txt.isNewline) { removeWhitespaceFrom = i; } i--; @@ -598,39 +836,9 @@ void TrimNewlinesFromOutputStream(Glue rightGlueToStopAt) } } - // Remove the glue (it will come before the whitespace, - // so index is still valid) - // Also remove any other non-matching right glues that come after, - // since they'll have lost their matching glues already - if (rightGlueToStopAt && rightGluePos > -1) { - i = rightGluePos; - while(i < _outputStream.Count) { - if (_outputStream [i] is Glue && ((Glue)_outputStream [i]).isRight) { - _outputStream.RemoveAt (i); - } else { - i++; - } - } - } - OutputStreamDirty(); } - void TrimFromExistingGlue() - { - int i = currentGlueIndex; - while (i < _outputStream.Count) { - var txt = _outputStream [i] as StringValue; - if (txt && !txt.isNonWhitespace) - _outputStream.RemoveAt (i); - else - i++; - } - - OutputStreamDirty(); - } - - // Only called when non-whitespace is appended void RemoveExistingGlue() { @@ -646,36 +854,6 @@ void RemoveExistingGlue() OutputStreamDirty(); } - int currentGlueIndex { - get { - for (int i = _outputStream.Count - 1; i >= 0; i--) { - var c = _outputStream [i]; - var glue = c as Glue; - if (glue) - return i; - else if (c is ControlCommand) // e.g. BeginString - break; - } - return -1; - } - } - - Runtime.Glue MatchRightGlueForLeftGlue (Glue leftGlue) - { - if (!leftGlue.isLeft) return null; - - for (int i = _outputStream.Count - 1; i >= 0; i--) { - var c = _outputStream [i]; - var g = c as Glue; - if (g && g.isRight && g.parent == leftGlue.parent) { - return g; - } else if (c is ControlCommand) // e.g. BeginString - break; - } - - return null; - } - internal bool outputStreamEndsInNewline { get { if (_outputStream.Count > 0) { @@ -732,17 +910,16 @@ internal void PushEvaluationStack(Runtime.Object obj) // Update origin when list is has something to indicate the list origin var rawList = listValue.value; - var names = rawList.originNames; - if (names != null) { - var origins = new List (); - foreach (var n in names) { + if (rawList.originNames != null) { + if( rawList.origins == null ) rawList.origins = new List(); + rawList.origins.Clear(); + + foreach (var n in rawList.originNames) { ListDefinition def = null; - story.listDefinitions.TryGetDefinition (n, out def); - if( !origins.Contains(def) ) - origins.Add (def); + story.listDefinitions.TryListGetDefinition (n, out def); + if( !rawList.origins.Contains(def) ) + rawList.origins.Add (def); } - - rawList.origins = origins; } } @@ -782,63 +959,89 @@ internal Runtime.Object PeekEvaluationStack() /// public void ForceEnd() { - while (callStack.canPopThread) - callStack.PopThread (); - - while (callStack.canPop) - callStack.Pop (); + callStack.Reset(); _currentChoices.Clear(); - currentContentObject = null; - previousContentObject = null; + currentPointer = Pointer.Null; + previousPointer = Pointer.Null; didSafeExit = true; } - // Don't make public since the method need to be wrapped in Story for visit counting - internal void SetChosenPath(Path path) + // Add 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. + void TrimWhitespaceFromFunctionEnd () { - // Changing direction, assume we need to clear current set of choices - _currentChoices.Clear (); + Debug.Assert (callStack.currentElement.type == PushPopType.Function); + + var functionStartPoint = callStack.currentElement.functionStartInOuputStream; - currentPath = path; + // 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 (functionStartPoint == -1) { + functionStartPoint = 0; + } - currentTurnIndex++; + // Trim whitespace from END of function call + for (int i = _outputStream.Count - 1; i >= functionStartPoint; i--) { + var obj = _outputStream [i]; + var txt = obj as StringValue; + var cmd = obj as ControlCommand; + if (!txt) continue; + if (cmd) break; + + if (txt.isNewline || txt.isInlineWhitespace) { + _outputStream.RemoveAt (i); + OutputStreamDirty (); + } else { + break; + } + } } - internal void StartExternalFunctionEvaluation (Container funcContainer, params object[] arguments) + internal void PopCallstack (PushPopType? popType = null) { - // We'll start a new callstack, so keep hold of the original, - // as well as the evaluation stack so we know if the function - // returned something - _originalCallstack = callStack; - _originalEvaluationStackHeight = evaluationStack.Count; + // Add the end of a function call, trim any whitespace from the end. + if (callStack.currentElement.type == PushPopType.Function) + TrimWhitespaceFromFunctionEnd (); - // Create a new base call stack element. - callStack = new CallStack (funcContainer); - callStack.currentElement.type = PushPopType.Function; + callStack.Pop (popType); + } - // Change the callstack the variableState is looking at to be - // this temporary function evaluation one. We'll restore it afterwards - variablesState.callStack = callStack; + // Don't make public since the method need to be wrapped in Story for visit counting + internal void SetChosenPath(Path path, bool incrementingTurnIndex) + { + // Changing direction, assume we need to clear current set of choices + _currentChoices.Clear (); + + var newPointer = story.PointerAtPath (path); + if (!newPointer.isNull && newPointer.index == -1) + newPointer.index = 0; + + currentPointer = newPointer; - // By setting ourselves in external function evaluation mode, - // we're saying it's okay to end the flow without a Done or End, - // but with a ~ return instead. - _isExternalFunctionEvaluation = true; + if( incrementingTurnIndex ) + currentTurnIndex++; + } + + internal void StartFunctionEvaluationFromGame (Container funcContainer, params object[] arguments) + { + callStack.Push (PushPopType.FunctionEvaluationFromGame, evaluationStack.Count); + callStack.currentElement.currentPointer = Pointer.StartOf (funcContainer); PassArgumentsToEvaluationStack (arguments); } internal void PassArgumentsToEvaluationStack (params object [] arguments) { - // Pass arguments onto the evaluation stack if (arguments != null) { for (int i = 0; i < arguments.Length; i++) { - if (!(arguments [i] is int || arguments [i] is float || arguments [i] is string)) { - throw new System.ArgumentException ("ink arguments when calling EvaluateFunction / ChoosePathStringWithParameters must be int, float or string"); + if (!(arguments [i] is int || arguments [i] is float || arguments [i] is string || arguments [i] is InkList)) { + throw new System.ArgumentException ("ink arguments when calling EvaluateFunction / ChoosePathStringWithParameters must be int, float, string or InkList. Argument was "+(arguments [i] == null ? "null" : arguments [i].GetType().Name)); } PushEvaluationStack (Runtime.Value.Create (arguments [i])); @@ -846,10 +1049,10 @@ internal void PassArgumentsToEvaluationStack (params object [] arguments) } } - internal bool TryExitExternalFunctionEvaluation () + internal bool TryExitFunctionEvaluationFromGame () { - if (_isExternalFunctionEvaluation && callStack.elements.Count == 1 && callStack.currentElement.type == PushPopType.Function) { - currentContentObject = null; + if( callStack.currentElement.type == PushPopType.FunctionEvaluationFromGame ) { + currentPointer = Pointer.Null; didSafeExit = true; return true; } @@ -857,27 +1060,27 @@ internal bool TryExitExternalFunctionEvaluation () return false; } - internal object CompleteExternalFunctionEvaluation () + internal object CompleteFunctionEvaluationFromGame () { + if (callStack.currentElement.type != PushPopType.FunctionEvaluationFromGame) { + throw new StoryException ("Expected external function evaluation to be complete. Stack trace: "+callStack.callStackTrace); + } + + int originalEvaluationStackHeight = callStack.currentElement.evaluationStackHeightWhenPushed; // 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 EvaluateFunction may // have passed too many arguments, and we currently have no way to check for that) Runtime.Object returnedObj = null; - while (evaluationStack.Count > _originalEvaluationStackHeight) { + while (evaluationStack.Count > originalEvaluationStackHeight) { var poppedObj = PopEvaluationStack (); if (returnedObj == null) returnedObj = poppedObj; } - // Restore our own state - callStack = _originalCallstack; - _originalCallstack = null; - _originalEvaluationStackHeight = 0; - - // Restore the callstack that the variablesState uses - variablesState.callStack = callStack; + // Finally, pop the external function evaluation + PopCallstack (PushPopType.FunctionEvaluationFromGame); // What did we get back? if (returnedObj) { @@ -901,14 +1104,15 @@ internal object CompleteExternalFunctionEvaluation () return null; } - internal void AddError(string message) + internal void AddError(string message, bool isWarning) { - // TODO: Could just add to output? - if (currentErrors == null) { - currentErrors = new List (); + if (!isWarning) { + if (currentErrors == null) currentErrors = new List (); + currentErrors.Add (message); + } else { + if (currentWarnings == null) currentWarnings = new List (); + currentWarnings.Add (message); } - - currentErrors.Add (message); } void OutputStreamDirty() @@ -920,17 +1124,18 @@ void OutputStreamDirty() // REMEMBER! REMEMBER! REMEMBER! // When adding state, update the Copy method and serialisation // REMEMBER! REMEMBER! REMEMBER! - + + + Dictionary _visitCounts; + Dictionary _turnIndices; + List _outputStream; bool _outputStreamTextDirty = true; bool _outputStreamTagsDirty = true; List _currentChoices; - // Temporary state only, during externally called function evaluation - bool _isExternalFunctionEvaluation; - CallStack _originalCallstack; - int _originalEvaluationStackHeight; + StatePatch _patch; } } diff --git a/ink-engine-runtime/Value.cs b/ink-engine-runtime/Value.cs index 7f00ff2..d301919 100644 --- a/ink-engine-runtime/Value.cs +++ b/ink-engine-runtime/Value.cs @@ -67,6 +67,11 @@ internal override Object Copy() { return Create (valueObject); } + + protected StoryException BadCastException (ValueType targetType) + { + return new StoryException ("Can't cast "+this.valueObject+" from " + this.valueType+" to "+targetType); + } } internal abstract class Value : Value @@ -115,7 +120,7 @@ public override Value Cast(ValueType newType) return new StringValue("" + this.value); } - throw new System.Exception ("Unexpected type cast of Value to new ValueType"); + throw BadCastException (newType); } } @@ -144,7 +149,7 @@ public override Value Cast(ValueType newType) return new StringValue("" + this.value.ToString(System.Globalization.CultureInfo.InvariantCulture)); } - throw new System.Exception ("Unexpected type cast of Value to new ValueType"); + throw BadCastException (newType); } } @@ -201,7 +206,7 @@ public override Value Cast(ValueType newType) } } - throw new System.Exception ("Unexpected type cast of Value to new ValueType"); + throw BadCastException (newType); } } @@ -223,7 +228,7 @@ public override Value Cast(ValueType newType) if (newType == valueType) return this; - throw new System.Exception ("Unexpected type cast of Value to new ValueType"); + throw BadCastException (newType); } public override string ToString () @@ -260,7 +265,7 @@ public override Value Cast(ValueType newType) if (newType == valueType) return this; - throw new System.Exception ("Unexpected type cast of Value to new ValueType"); + throw BadCastException (newType); } public override string ToString () @@ -282,15 +287,10 @@ public override ValueType valueType { } } - // Truthy if it contains any non-zero items + // Truthy if it is non-empty public override bool isTruthy { get { - foreach (var kv in value) { - int listItemIntValue = kv.Value; - if (listItemIntValue != 0) - return true; - } - return false; + return value.Count > 0; } } @@ -324,7 +324,7 @@ public override Value Cast (ValueType newType) if (newType == valueType) return this; - throw new System.Exception ("Unexpected type cast of Value to new ValueType"); + throw BadCastException (newType); } public ListValue () : base(null) { diff --git a/ink-engine-runtime/VariableReference.cs b/ink-engine-runtime/VariableReference.cs index 396bb11..0061f89 100644 --- a/ink-engine-runtime/VariableReference.cs +++ b/ink-engine-runtime/VariableReference.cs @@ -10,7 +10,7 @@ internal class VariableReference : Runtime.Object internal Container containerForCount { get { - return this.ResolvePath (pathForCount) as Container; + return this.ResolvePath (pathForCount).container; } } diff --git a/ink-engine-runtime/VariablesState.cs b/ink-engine-runtime/VariablesState.cs index 9b7302a..5e8d49c 100644 --- a/ink-engine-runtime/VariablesState.cs +++ b/ink-engine-runtime/VariablesState.cs @@ -12,6 +12,8 @@ public class VariablesState : IEnumerable internal delegate void VariableChanged(string variableName, Runtime.Object newValue); internal event VariableChanged variableChangedEvent; + internal StatePatch patch; + internal bool batchObservingVariableChanges { get { @@ -20,20 +22,20 @@ internal bool batchObservingVariableChanges set { _batchObservingVariableChanges = value; if (value) { - _changedVariables = new HashSet (); + _changedVariablesForBatchObs = new HashSet (); } // Finished observing variables in a batch - now send // notifications for changed variables all in one go. else { - if (_changedVariables != null) { - foreach (var variableName in _changedVariables) { + if (_changedVariablesForBatchObs != null) { + foreach (var variableName in _changedVariablesForBatchObs) { var currentValue = _globalVariables [variableName]; variableChangedEvent (variableName, currentValue); } } - _changedVariables = null; + _changedVariablesForBatchObs = null; } } } @@ -61,18 +63,25 @@ public object this[string variableName] { get { Runtime.Object varContents; - if ( _globalVariables.TryGetValue (variableName, out varContents) ) + + if (patch != null && patch.TryGetGlobal(variableName, out varContents)) + return (varContents as Runtime.Value).valueObject; + + // Search main dictionary first. + // If it's not found, it might be because the story content has changed, + // and the original default value hasn't be instantiated. + // Should really warn somehow, but it's difficult to see how...! + if ( _globalVariables.TryGetValue (variableName, out varContents) || + _defaultGlobalVariables.TryGetValue(variableName, out varContents) ) return (varContents as Runtime.Value).valueObject; - else + else { return null; + } } set { - - // This is the main - if (!_globalVariables.ContainsKey (variableName)) { - throw new StoryException ("Variable '" + variableName + "' doesn't exist, so can't be set."); - } - + if (!_defaultGlobalVariables.ContainsKey (variableName)) + throw new StoryException ("Cannot assign to a variable ("+variableName+") that hasn't been declared in the story"); + var val = Runtime.Value.Create(value); if (val == null) { if (value == null) { @@ -106,32 +115,96 @@ internal VariablesState (CallStack callStack, ListDefinitionsOrigin listDefsOrig _listDefsOrigin = listDefsOrigin; } - internal void CopyFrom (VariablesState toCopy) + internal void ApplyPatch() { - _globalVariables = new Dictionary (toCopy._globalVariables); + foreach(var namedVar in patch.globals) { + _globalVariables[namedVar.Key] = namedVar.Value; + } - variableChangedEvent = toCopy.variableChangedEvent; + if(_changedVariablesForBatchObs != null ) { + foreach (var name in patch.changedVariables) + _changedVariablesForBatchObs.Add(name); + } - if (toCopy.batchObservingVariableChanges != batchObservingVariableChanges) { + patch = null; + } - if (toCopy.batchObservingVariableChanges) { - _batchObservingVariableChanges = true; - _changedVariables = new HashSet (toCopy._changedVariables); + internal void SetJsonToken(Dictionary jToken) + { + _globalVariables.Clear(); + + foreach (var varVal in _defaultGlobalVariables) { + object loadedToken; + if( jToken.TryGetValue(varVal.Key, out loadedToken) ) { + _globalVariables[varVal.Key] = Json.JTokenToRuntimeObject(loadedToken); } else { - _batchObservingVariableChanges = false; - _changedVariables = null; + _globalVariables[varVal.Key] = varVal.Value; } } } - - internal Dictionary jsonToken + + /// + /// When saving out JSON state, we can skip saving global values that + /// remain equal to the initial values that were declared in ink. + /// This makes the save file (potentially) much smaller assuming that + /// at least a portion of the globals haven't changed. However, it + /// can also take marginally longer to save in the case that the + /// majority HAVE changed, since it has to compare all globals. + /// It may also be useful to turn this off for testing worst case + /// save timing. + /// + public static bool dontSaveDefaultValues = true; + + internal void WriteJson(SimpleJson.Writer writer) { - get { - return Json.DictionaryRuntimeObjsToJObject(_globalVariables); + writer.WriteObjectStart(); + foreach (var keyVal in _globalVariables) + { + var name = keyVal.Key; + var val = keyVal.Value; + + if(dontSaveDefaultValues) { + // Don't write out values that are the same as the default global values + Runtime.Object defaultVal; + if (_defaultGlobalVariables.TryGetValue(name, out defaultVal)) + { + if (RuntimeObjectsEqual(val, defaultVal)) + continue; + } + } + + + writer.WritePropertyStart(name); + Json.WriteRuntimeObject(writer, val); + writer.WritePropertyEnd(); } - set { - _globalVariables = Json.JObjectToDictionaryRuntimeObjs (value); + writer.WriteObjectEnd(); + } + + internal bool RuntimeObjectsEqual(Runtime.Object obj1, Runtime.Object obj2) + { + if (obj1.GetType() != obj2.GetType()) return false; + + // Perform equality on int/float manually to avoid boxing + var intVal = obj1 as IntValue; + if( intVal != null ) { + return intVal.value == ((IntValue)obj2).value; + } + + var floatVal = obj1 as FloatValue; + if (floatVal != null) + { + return floatVal.value == ((FloatValue)obj2).value; + } + + // Other Value type (using proper Equals: list, string, divert path) + var val1 = obj1 as Value; + var val2 = obj2 as Value; + if( val1 != null ) { + return val1.valueObject.Equals(val2.valueObject); } + + throw new System.Exception("FastRoughDefinitelyEquals: Unsupported runtime object type: "+obj1.GetType()); } internal Runtime.Object GetVariableWithName(string name) @@ -139,6 +212,18 @@ internal Runtime.Object GetVariableWithName(string name) return GetVariableWithName (name, -1); } + internal Runtime.Object TryGetDefaultVariableValue (string name) + { + Runtime.Object val = null; + _defaultGlobalVariables.TryGetValue (name, out val); + return val; + } + + internal bool GlobalVariableExistsWithName(string name) + { + return _globalVariables.ContainsKey(name) || _defaultGlobalVariables != null && _defaultGlobalVariables.ContainsKey(name); + } + Runtime.Object GetVariableWithName(string name, int contextIndex) { Runtime.Object varValue = GetRawVariableWithName (name, contextIndex); @@ -158,9 +243,21 @@ Runtime.Object GetRawVariableWithName(string name, int contextIndex) // 0 context = global if (contextIndex == 0 || contextIndex == -1) { + if (patch != null && patch.TryGetGlobal(name, out varValue)) + return varValue; + if ( _globalVariables.TryGetValue (name, out varValue) ) return varValue; + // Getting variables can actually happen during globals set up since you can do + // VAR x = A_LIST_ITEM + // So _defaultGlobalVariables may be null. + // We need to do this check though in case a new global is added, so we need to + // revert to the default globals dictionary since an initial value hasn't yet been set. + if( _defaultGlobalVariables != null && _defaultGlobalVariables.TryGetValue(name, out varValue) ) { + return varValue; + } + var listItemValue = _listDefsOrigin.FindSingleItemListWithName (name); if (listItemValue) return listItemValue; @@ -169,9 +266,6 @@ Runtime.Object GetRawVariableWithName(string name, int contextIndex) // Temporary varValue = _callStack.GetTemporaryVariableWithName (name, contextIndex); - if (varValue == null) - throw new System.Exception ("RUNTIME ERROR: Variable '"+name+"' could not be found in context '"+contextIndex+"'. This shouldn't be possible so is a bug in the ink engine. Please try to construct a minimal story that reproduces the problem and report to inkle, thank you!"); - return varValue; } @@ -190,7 +284,7 @@ internal void Assign(VariableAssignment varAss, Runtime.Object value) if (varAss.isNewDeclaration) { setGlobal = varAss.isGlobal; } else { - setGlobal = _globalVariables.ContainsKey (name); + setGlobal = GlobalVariableExistsWithName (name); } // Constructing new variable pointer reference @@ -227,6 +321,11 @@ internal void Assign(VariableAssignment varAss, Runtime.Object value) } } + internal void SnapshotDefaultGlobals () + { + _defaultGlobalVariables = new Dictionary (_globalVariables); + } + void RetainListOriginsForAssignment (Runtime.Object oldValue, Runtime.Object newValue) { var oldList = oldValue as ListValue; @@ -235,19 +334,26 @@ void RetainListOriginsForAssignment (Runtime.Object oldValue, Runtime.Object new newList.value.SetInitialOriginNames (oldList.value.originNames); } - void SetGlobal(string variableName, Runtime.Object value) + internal void SetGlobal(string variableName, Runtime.Object value) { Runtime.Object oldValue = null; - _globalVariables.TryGetValue (variableName, out oldValue); + if( patch == null || !patch.TryGetGlobal(variableName, out oldValue) ) + _globalVariables.TryGetValue (variableName, out oldValue); ListValue.RetainListOriginsForAssignment (oldValue, value); - _globalVariables [variableName] = value; + if (patch != null) + patch.SetGlobal(variableName, value); + else + _globalVariables [variableName] = value; if (variableChangedEvent != null && !value.Equals (oldValue)) { if (batchObservingVariableChanges) { - _changedVariables.Add (variableName); + if (patch != null) + patch.AddChangedVariable(variableName); + else if(_changedVariablesForBatchObs != null) + _changedVariablesForBatchObs.Add (variableName); } else { variableChangedEvent (variableName, value); } @@ -286,7 +392,7 @@ VariablePointerValue ResolveVariablePointer(VariablePointerValue varPointer) // 1+ if named variable is a temporary in a particular call stack element int GetContextIndexOfVariableNamed(string varName) { - if (_globalVariables.ContainsKey (varName)) + if (GlobalVariableExistsWithName(varName)) return 0; return _callStack.currentElementIndex; @@ -294,9 +400,11 @@ int GetContextIndexOfVariableNamed(string varName) Dictionary _globalVariables; + Dictionary _defaultGlobalVariables; + // Used for accessing temporary variables CallStack _callStack; - HashSet _changedVariables; + HashSet _changedVariablesForBatchObs; ListDefinitionsOrigin _listDefsOrigin; } } diff --git a/ink-godot-example.csproj b/ink-godot-example.csproj index d2cf272..5898f2a 100644 --- a/ink-godot-example.csproj +++ b/ink-godot-example.csproj @@ -1,5 +1,6 @@  - + Debug AnyCPU @@ -11,6 +12,8 @@ v4.5 .mono\temp\obj $(BaseIntermediateOutputPath)\$(Configuration) + Debug + Release true @@ -39,12 +42,12 @@ - $(ProjectDir)\.mono\assemblies\GodotSharp.dll False + $(ProjectDir)/.mono/assemblies/$(ApiConfiguration)/GodotSharp.dll - $(ProjectDir)\.mono\assemblies\GodotSharpEditor.dll False + $(ProjectDir)/.mono/assemblies/$(ApiConfiguration)/GodotSharpEditor.dll @@ -65,8 +68,12 @@ + + + + diff --git a/project.godot b/project.godot index 0d7884d..ebf4e3d 100644 --- a/project.godot +++ b/project.godot @@ -6,7 +6,12 @@ ; [section] ; section goes between [] ; param=value ; assign values to parameters -config_version=3 +config_version=4 + +_global_script_classes=[ ] +_global_script_class_icons={ + +} [application] @@ -24,8 +29,11 @@ singletons=[ ] [input] -ui_back=[ Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"alt":false,"shift":false,"control":false,"meta":false,"command":false,"pressed":false,"scancode":16777220,"unicode":0,"echo":false,"script":null) +ui_back={ +"deadzone": 0.5, +"events": [ Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"alt":false,"shift":false,"control":false,"meta":false,"command":false,"pressed":false,"scancode":16777220,"unicode":0,"echo":false,"script":null) ] +} [rendering]