diff --git a/Documentation/WritingWithInk.md b/Documentation/WritingWithInk.md index 56f8f7d2..165e7771 100644 --- a/Documentation/WritingWithInk.md +++ b/Documentation/WritingWithInk.md @@ -264,7 +264,7 @@ You can't use too much glue: multiple glues next to each other have no additiona Combining knots, options and diverts gives us the basic structure of a choose-your-own game. - == paragraph_1 === + === paragraph_1 === You stand by the wall of Analand, sword in hand. * [Open the gate] -> paragraph_2 * [Smash down the gate] -> paragraph_3 @@ -1790,7 +1790,7 @@ But this flat structure makes certain things difficult: for example, imagine a g === outside_honolulu === We arrived at the large island of Honolulu. - (postscript) - {crossing_the_date_line(-> done)} + -> crossing_the_date_line(-> done) - (done) -> END @@ -1799,7 +1799,7 @@ But this flat structure makes certain things difficult: for example, imagine a g === outside_pitcairn_island === The boat sailed along the water towards the tiny island. - (postscript) - {crossing_the_date_line(-> done)} + -> crossing_the_date_line(-> done) - (done) -> END diff --git a/build_for_inky.command b/build_for_inky.command index 23aaf8d5..0f23a199 100755 --- a/build_for_inky.command +++ b/build_for_inky.command @@ -42,8 +42,8 @@ mkbundle --static --sdk /Library/Frameworks/Mono.framework/Versions/Current --de cp inklecate_mac ../../../BuildForInky/ cp inklecate.exe ../../../BuildForInky/inklecate_win.exe -cp inklecate.exe.mdb ../../../BuildForInky +cp inklecate.pdb ../../../BuildForInky cp ink-engine-runtime.dll ../../../BuildForInky -cp ink-engine-runtime.dll.mdb ../../../BuildForInky +cp ink-engine-runtime.pdb ../../../BuildForInky cp ink_compiler.dll ../../../BuildForInky -cp ink_compiler.dll.mdb ../../../BuildForInky \ No newline at end of file +cp ink_compiler.pdb ../../../BuildForInky \ No newline at end of file diff --git a/compiler/InkParser/InkParser_Logic.cs b/compiler/InkParser/InkParser_Logic.cs index 64152826..e977fa52 100644 --- a/compiler/InkParser/InkParser_Logic.cs +++ b/compiler/InkParser/InkParser_Logic.cs @@ -29,6 +29,9 @@ protected Parsed.Object LogicLine() var result = Expect(afterTilda, "expression after '~'", recoveryRule: SkipToNextLine) as Parsed.Object; + // Prevent further errors, already reported expected expression and have skipped to next line. + if (result == null) return new ContentList(); + // Parse all expressions, but tell the writer off if they did something useless like: // ~ 5 + 4 // And even: diff --git a/compiler/ParsedHierarchy/Object.cs b/compiler/ParsedHierarchy/Object.cs index c2eed9e2..f05f7202 100644 --- a/compiler/ParsedHierarchy/Object.cs +++ b/compiler/ParsedHierarchy/Object.cs @@ -194,9 +194,15 @@ public T AddContent(T subContent) where T : Parsed.Object if (content == null) { content = new List (); } - - subContent.parent = this; - content.Add (subContent); + + // Make resilient to content not existing, which can happen + // in the case of parse errors where we've already reported + // an error but still want a valid structure so we can + // carry on parsing. + if( subContent ) { + subContent.parent = this; + content.Add(subContent); + } return subContent; } diff --git a/compiler/ParsedHierarchy/Weave.cs b/compiler/ParsedHierarchy/Weave.cs index 9027a228..6b6dc736 100644 --- a/compiler/ParsedHierarchy/Weave.cs +++ b/compiler/ParsedHierarchy/Weave.cs @@ -197,14 +197,6 @@ public override Runtime.Object GenerateRuntimeObject () else { AddGeneralRuntimeContent (obj.runtimeObject); } - - // Keep track of nested choices within this (possibly complex) object, - // so that the next Gather knows whether to auto-enter - // (it auto-enters when there are no choices) - var innerChoices = obj.FindAll (); - if (innerChoices.Count > 0) - hasSeenChoiceInSection = true; - } } @@ -357,25 +349,123 @@ void AddGeneralRuntimeContent(Runtime.Object content) void PassLooseEndsToAncestors() { - if (looseEnds.Count > 0) { + if (looseEnds.Count == 0) return; + + // Search for Weave ancestor to pass loose ends to for gathering. + // There are two types depending on whether the current weave + // is separated by a conditional or sequence. + // - An "inner" weave is one that is directly connected to the current + // weave - i.e. you don't have to pass through a conditional or + // sequence to get to it. We're allowed to pass all loose ends to + // one of these. + // - An "outer" weave is one that is outside of a conditional/sequence + // that the current weave is nested within. We're only allowed to + // pass gathers (i.e. 'normal flow') loose ends up there, not normal + // choices. The rule is that choices have to be diverted explicitly + // by the author since it's ambiguous where flow should go otherwise. + // + // e.g.: + // + // - top <- e.g. outer weave + // {true: + // * choice <- e.g. inner weave + // * * choice 2 + // more content <- e.g. current weave + // * choice 2 + // } + // - more of outer weave + // + Weave closestInnerWeaveAncestor = null; + Weave closestOuterWeaveAncestor = null; + + // Find inner and outer ancestor weaves as defined above. + bool nested = false; + for (var ancestor = this.parent; ancestor != null; ancestor = ancestor.parent) + { + + // Found ancestor? + var weaveAncestor = ancestor as Weave; + if (weaveAncestor != null) + { + if (!nested && closestInnerWeaveAncestor == null) + closestInnerWeaveAncestor = weaveAncestor; + + if (nested && closestOuterWeaveAncestor == null) + closestOuterWeaveAncestor = weaveAncestor; + } + + + // Weaves nested within Sequences or Conditionals are + // "sealed" - any loose ends require explicit diverts. + if (ancestor is Sequence || ancestor is Conditional) + nested = true; + } + + // No weave to pass loose ends to at all? + if (closestInnerWeaveAncestor == null && closestOuterWeaveAncestor == null) + return; + + // Follow loose end passing logic as defined above + for (int i = looseEnds.Count - 1; i >= 0; i--) { + var looseEnd = looseEnds[i]; + + bool received = false; - var weaveAncestor = closestWeaveAncestor; - if (weaveAncestor) { - weaveAncestor.ReceiveLooseEnds (looseEnds); - looseEnds = null; + // This weave is nested within a conditional or sequence: + // - choices can only be passed up to direct ancestor ("inner") weaves + // - gathers can be passed up to either, but favour the closer (inner) weave + // if there is one + if(nested) { + if( looseEnd is Choice && closestInnerWeaveAncestor != null) { + closestInnerWeaveAncestor.ReceiveLooseEnd(looseEnd); + received = true; + } + + else if( !(looseEnd is Choice) ) { + var receivingWeave = closestInnerWeaveAncestor ?? closestOuterWeaveAncestor; + if(receivingWeave != null) { + receivingWeave.ReceiveLooseEnd(looseEnd); + received = true; + } + } + } + + // No nesting, all loose ends can be safely passed up + else { + closestInnerWeaveAncestor.ReceiveLooseEnd(looseEnd); + received = true; } + + if(received) looseEnds.RemoveAt(i); } } - public void ReceiveLooseEnds(List childWeaveLooseEnds) + void ReceiveLooseEnd(IWeavePoint childWeaveLooseEnd) { - looseEnds.AddRange (childWeaveLooseEnds); + looseEnds.Add(childWeaveLooseEnd); } public override void ResolveReferences(Story context) { base.ResolveReferences (context); + // Check that choices nested within conditionals and sequences are terminated + if( looseEnds != null && looseEnds.Count > 0 ) { + var isNestedWeave = false; + for (var ancestor = this.parent; ancestor != null; ancestor = ancestor.parent) + { + if (ancestor is Sequence || ancestor is Conditional) + { + isNestedWeave = true; + break; + } + } + if (isNestedWeave) + { + ValidateTermination(BadNestedTerminationHandler); + } + } + foreach(var gatherPoint in gatherPointsToResolve) { gatherPoint.divert.targetPath = gatherPoint.targetRuntimeObj.path; } @@ -496,6 +586,32 @@ public void ValidateTermination (BadTerminationHandler badTerminationHandler) } } + void BadNestedTerminationHandler(Parsed.Object terminatingObj) + { + Conditional conditional = null; + for (var ancestor = terminatingObj.parent; ancestor != null; ancestor = ancestor.parent) { + if( ancestor is Sequence || ancestor is Conditional ) { + conditional = ancestor as Conditional; + break; + } + } + + var errorMsg = "Choices nested in conditionals or sequences need to explicitly divert afterwards."; + + // Tutorialise proper choice syntax if this looks like a single choice within a condition, e.g. + // { condition: + // * choice + // } + if (conditional != null) { + var numChoices = conditional.FindAll().Count; + if( numChoices == 1 ) { + errorMsg = "Choices with conditions should be written: '* {condition} choice'. Otherwise, "+ errorMsg.ToLower(); + } + } + + Error(errorMsg, terminatingObj); + } + void ValidateFlowOfObjectsTerminates (IEnumerable objFlow, Parsed.Object defaultObj, BadTerminationHandler badTerminationHandler) { bool terminated = false; @@ -527,16 +643,6 @@ void ValidateFlowOfObjectsTerminates (IEnumerable objFlow, Parsed badTerminationHandler (terminatingObj); } } - - Weave closestWeaveAncestor { - get { - var ancestor = this.parent; - while (ancestor && !(ancestor is Weave)) { - ancestor = ancestor.parent; - } - return (Weave)ancestor; - } - } bool WeavePointHasLooseEnd(IWeavePoint weavePoint) { diff --git a/ink-engine-runtime/Story.cs b/ink-engine-runtime/Story.cs index ccfa1b65..f4afe74c 100644 --- a/ink-engine-runtime/Story.cs +++ b/ink-engine-runtime/Story.cs @@ -2155,6 +2155,10 @@ bool TryFollowDefaultInvisibleChoice() var choice = invisibleChoices [0]; + // 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; + ChoosePath (choice.targetPath, incrementingTurnIndex: false); return true; diff --git a/tests/Tests.cs b/tests/Tests.cs index 5e05ddd0..7b5fd80f 100644 --- a/tests/Tests.cs +++ b/tests/Tests.cs @@ -356,8 +356,8 @@ public void TestConditionalChoiceInWeave() { - true: * [go to a stitch] -> a_stitch } -- gather shouldn't be seen --> END +- gather should be seen +-> DONE = a_stitch result @@ -366,9 +366,7 @@ public void TestConditionalChoiceInWeave() Story story = CompileString(storyStr); - // Extra newline is because there's a choice object sandwiched there, - // so it can't be absorbed :-/ - Assert.AreEqual("start\n", story.Continue()); + Assert.AreEqual("start\ngather should be seen\n", story.ContinueMaximally()); Assert.AreEqual(1, story.currentChoices.Count); story.ChooseChoiceIndex(0); @@ -382,14 +380,13 @@ public void TestConditionalChoiceInWeave2() var storyStr = @" - first gather - * option 1 - * option 2 + * [option 1] + * [option 2] - the main gather {false: - * unreachable option + * unreachable option -> END } -- unrechable gather - "; +- bottom gather"; Story story = CompileString(storyStr); @@ -399,7 +396,7 @@ public void TestConditionalChoiceInWeave2() story.ChooseChoiceIndex(0); - Assert.AreEqual("option 1\nthe main gather\n", story.ContinueMaximally()); + Assert.AreEqual("the main gather\nbottom gather\n", story.ContinueMaximally()); Assert.AreEqual(0, story.currentChoices.Count); } @@ -2854,6 +2851,7 @@ public void TestWeaveWithinSequence () { shuffle: - * choice nextline + -> END } "; var story = CompileString (storyStr); @@ -2868,6 +2866,20 @@ public void TestWeaveWithinSequence () } + [Test()] + public void TestNestedChoiceError() + { + var storyStr = + @" +{ true: + * choice +} +"; + CompileString(storyStr, testingErrors:true); + Assert.IsTrue(HadError("need to explicitly divert")); + } + + [Test ()] public void TestStitchNamingCollision () { @@ -3627,6 +3639,25 @@ public void TestChoiceThreadForking() Assert.IsFalse(story.hasWarning); } + + [Test()] + public void TestFallbackChoiceOnThread() + { + var storyStr = + @" +<- knot + +== knot + ~ temp x = 1 + * -> + Should be 1 not 0: {x}. + -> DONE +"; + + var story = CompileString(storyStr); + Assert.AreEqual("Should be 1 not 0: 1.\n", story.Continue()); + } + // Helper compile function protected Story CompileString(string str, bool countAllVisits = false, bool testingErrors = false) {