From d0d70be2b0a9db66e2a6a9cbb5e57002ff2db41c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Garc=C3=ADa?= Date: Tue, 25 Jun 2024 23:45:07 +0200 Subject: [PATCH] Match features with C# Ink v1.2.0 --- gradle.properties | 4 +- .../com/bladecoder/ink/runtime/CallStack.java | 22 ++++-- .../com/bladecoder/ink/runtime/Choice.java | 12 ++++ .../com/bladecoder/ink/runtime/Container.java | 12 +++- .../com/bladecoder/ink/runtime/InkList.java | 68 +++++++++++++------ .../java/com/bladecoder/ink/runtime/Json.java | 26 +++++++ .../ink/runtime/ListDefinitionsOrigin.java | 2 +- .../ink/runtime/NativeFunctionCall.java | 4 +- .../bladecoder/ink/runtime/SimpleJson.java | 11 +++ .../com/bladecoder/ink/runtime/Story.java | 48 +++++++++++-- .../bladecoder/ink/runtime/StoryState.java | 25 ++++++- .../ink/runtime/VariablesState.java | 61 ++++++++++------- .../ink/runtime/test/TagSpecTest.java | 1 - 13 files changed, 234 insertions(+), 62 deletions(-) diff --git a/gradle.properties b/gradle.properties index 26922e0..bb6e643 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -# Matching Ink v1.1.1 -version=1.1.2 +# Matching Ink v1.2.0 +version=1.2.0 diff --git a/src/main/java/com/bladecoder/ink/runtime/CallStack.java b/src/main/java/com/bladecoder/ink/runtime/CallStack.java index 41c52dc..6be1591 100644 --- a/src/main/java/com/bladecoder/ink/runtime/CallStack.java +++ b/src/main/java/com/bladecoder/ink/runtime/CallStack.java @@ -79,15 +79,24 @@ public Thread(HashMap jThreadObj, Story storyContext) throws Exc pointer.container = threadPointerResult.getContainer(); pointer.index = (int) jElementObj.get("idx"); - if (threadPointerResult.obj == null) + if (threadPointerResult.obj == null) { throw new 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.getPath().toString() - + "' to recover. Has the story changed since this save data was created?"); + } else if (threadPointerResult.approximate) { + if (pointer.container != null) { + storyContext.warning( + "When loading state, exact internal story location couldn't be found: '" + + currentContainerPathStr + "', so it was approximated to '" + + pointer.container.getPath().toString() + + "' to recover. Has the story changed since this save data was created?"); + } else { + storyContext.warning( + "When loading state, exact internal story location couldn't be found: '" + + currentContainerPathStr + + "' and it may not be recoverable. Has the story changed since this save data was created?"); + } + } } boolean inExpressionEvaluation = (boolean) jElementObj.get("exp"); @@ -282,6 +291,7 @@ public RTObject getTemporaryVariableWithName(String name) { // Get variable value, dereferencing a variable pointer if necessary public RTObject getTemporaryVariableWithName(String name, int contextIndex) { + // contextIndex 0 means global, so index is actually 1-based if (contextIndex == -1) contextIndex = getCurrentElementIndex() + 1; Element contextElement = getCallStack().get(contextIndex - 1); diff --git a/src/main/java/com/bladecoder/ink/runtime/Choice.java b/src/main/java/com/bladecoder/ink/runtime/Choice.java index 772b9e6..2297283 100644 --- a/src/main/java/com/bladecoder/ink/runtime/Choice.java +++ b/src/main/java/com/bladecoder/ink/runtime/Choice.java @@ -72,4 +72,16 @@ public void setText(String value) { public void setThreadAtGeneration(CallStack.Thread value) { threadAtGeneration = value; } + + public Choice clone() { + Choice copy = new Choice(); + copy.text = text; + copy.sourcePath = sourcePath; + copy.index = index; + copy.targetPath = targetPath; + copy.originalThreadIndex = originalThreadIndex; + copy.isInvisibleDefault = isInvisibleDefault; + if (threadAtGeneration != null) copy.threadAtGeneration = threadAtGeneration.copy(); + return copy; + } } diff --git a/src/main/java/com/bladecoder/ink/runtime/Container.java b/src/main/java/com/bladecoder/ink/runtime/Container.java index a17cd07..431cd5a 100644 --- a/src/main/java/com/bladecoder/ink/runtime/Container.java +++ b/src/main/java/com/bladecoder/ink/runtime/Container.java @@ -224,8 +224,18 @@ public SearchResult contentAtPath(Path path, int partialPathStart, int partialPa break; } + // Are we about to loop into another container? + // Is the object a container as expected? It might + // no longer be if the content has shuffled around, so what + // was originally a container no longer is. + Container nextContainer = foundObj instanceof Container ? (Container) foundObj : null; + if (i < partialPathLength - 1 && nextContainer == null) { + result.approximate = true; + break; + } + currentObj = foundObj; - currentContainer = foundObj instanceof Container ? (Container) foundObj : null; + currentContainer = nextContainer; } result.obj = currentObj; diff --git a/src/main/java/com/bladecoder/ink/runtime/InkList.java b/src/main/java/com/bladecoder/ink/runtime/InkList.java index 1eb214d..88c860e 100644 --- a/src/main/java/com/bladecoder/ink/runtime/InkList.java +++ b/src/main/java/com/bladecoder/ink/runtime/InkList.java @@ -70,7 +70,9 @@ public InkList(InkList otherList) { /** * Converts a string to an ink list and returns for use in the story. */ - public static InkList FromString(String myListItem, Story originStory) throws Exception { + public static InkList fromString(String myListItem, Story originStory) throws Exception { + if (myListItem == null || myListItem.isEmpty()) return new InkList(); + ListValue listValue = originStory.getListDefinitions().findSingleItemListWithName(myListItem); if (listValue != null) return new InkList(listValue.value); else @@ -321,7 +323,7 @@ public InkList listWithSubRange(Object minBound, Object maxBound) throws StoryEx if (maxBound instanceof Integer) maxValue = (int) maxBound; else { - if (minBound instanceof InkList && ((InkList) minBound).size() > 0) + if (maxBound instanceof InkList && ((InkList) maxBound).size() > 0) maxValue = ((InkList) maxBound).getMaxItem().getValue(); } @@ -355,6 +357,17 @@ public String getSingleOriginListName() { return name; } + /** + * If you have an InkList that's known to have one single item, this is a convenient way to get it. + */ + public InkListItem getSingleItem() { + for (Map.Entry item : this.entrySet()) { + return item.getKey(); + } + + return null; + } + /** * The inverse of the list, equivalent to calling LIST_INVERSE(list) in ink */ @@ -398,7 +411,8 @@ public InkList getAll() { /** * Adds the given item to the ink list. Note that the item must come from a list * definition that is already "known" to this list, so that the item's value can - * be looked up. By "known", we mean that it already has items in it from that + * be looked up. + * By "known", we mean that it already has items in it from that * source, or it did at one point - it can't be a completely fresh empty list, * or a list that only contains items from a different list definition. * @@ -438,39 +452,53 @@ public void addItem(InkListItem item) throws Exception { * be looked up. By "known", we mean that it already has items in it from that * source, or it did at one point - it can't be a completely fresh empty list, * or a list that only contains items from a different list definition. + * You can also provide the Story object, so in the case of an unknown element, it can be created fresh. * * @throws Exception */ - public void addItem(String itemName) throws Exception { + public void addItem(String itemName, Story storyObject) throws Exception { ListDefinition foundListDef = null; - for (ListDefinition origin : origins) { - if (origin.containsItemWithName(itemName)) { - if (foundListDef != null) { - throw new Exception( - "Could not add the item " + itemName + " to this list because it could come from either " - + origin.getName() + " or " + foundListDef.getName()); - } else { - foundListDef = origin; + if (origins != null) { + for (ListDefinition origin : origins) { + if (origin.containsItemWithName(itemName)) { + if (foundListDef != null) { + throw new Exception("Could not add the item " + itemName + + " to this list because it could come from either " + origin.getName() + " or " + + foundListDef.getName()); + } else { + foundListDef = origin; + } } } } - if (foundListDef == null) - throw new Exception("Could not add the item " + itemName - + " to this list because it isn't known to any list definitions previously associated with this " - + "list."); + if (foundListDef == null) { + if (storyObject == null) { + throw new Exception("Could not add the item " + itemName + + " to this list because it isn't known to any list definitions previously associated with this " + + "list."); + } else { + Entry newItem = + fromString(itemName, storyObject).getOrderedItems().get(0); + this.put(newItem.getKey(), newItem.getValue()); + } + } else { + InkListItem item = new InkListItem(foundListDef.getName(), itemName); + Integer itemVal = foundListDef.getValueForItem(item); + this.put(item, itemVal != null ? itemVal : 0); + } + } - InkListItem item = new InkListItem(foundListDef.getName(), itemName); - Integer itemVal = foundListDef.getValueForItem(item); - this.put(item, itemVal != null ? itemVal : 0); + public void addItem(String itemName) throws Exception { + addItem(itemName, null); } /** * Returns true if this ink list contains an item with the given short name * (ignoring the original list where it was defined). */ - public boolean ContainsItemNamed(String itemName) { + public boolean containsItemNamed(String itemName) { for (Map.Entry itemWithValue : this.entrySet()) { if (itemWithValue.getKey().getItemName().equals(itemName)) return true; } diff --git a/src/main/java/com/bladecoder/ink/runtime/Json.java b/src/main/java/com/bladecoder/ink/runtime/Json.java index 9d4f34f..7075b2e 100644 --- a/src/main/java/com/bladecoder/ink/runtime/Json.java +++ b/src/main/java/com/bladecoder/ink/runtime/Json.java @@ -596,9 +596,22 @@ static Choice jObjectToChoice(HashMap jObj) throws Exception { choice.sourcePath = jObj.get("originalChoicePath").toString(); choice.originalThreadIndex = (int) jObj.get("originalThreadIndex"); choice.setPathStringOnChoice(jObj.get("targetPath").toString()); + choice.tags = jArrayToTags(jObj, choice); return choice; } + private static List jArrayToTags(HashMap jObj, Choice choice) { + Object jArray = jObj.get("tags"); + if (jArray == null) return null; + + List tags = new ArrayList<>(); + for (Object stringValue : (List) jArray) { + tags.add(stringValue.toString()); + } + + return tags; + } + public static void writeChoice(SimpleJson.Writer writer, Choice choice) throws Exception { writer.writeObjectStart(); writer.writeProperty("text", choice.getText()); @@ -606,9 +619,22 @@ public static void writeChoice(SimpleJson.Writer writer, Choice choice) throws E writer.writeProperty("originalChoicePath", choice.sourcePath); writer.writeProperty("originalThreadIndex", choice.originalThreadIndex); writer.writeProperty("targetPath", choice.getPathStringOnChoice()); + writeChoiceTags(writer, choice); writer.writeObjectEnd(); } + private static void writeChoiceTags(SimpleJson.Writer writer, Choice choice) throws Exception { + if (choice.tags == null || choice.tags.isEmpty()) return; + writer.writePropertyStart("tags"); + writer.writeArrayStart(); + for (String tag : choice.tags) { + writer.write(tag); + } + + writer.writeArrayEnd(); + writer.writePropertyEnd(); + } + static void writeInkList(SimpleJson.Writer writer, ListValue listVal) throws Exception { InkList rawList = listVal.getValue(); diff --git a/src/main/java/com/bladecoder/ink/runtime/ListDefinitionsOrigin.java b/src/main/java/com/bladecoder/ink/runtime/ListDefinitionsOrigin.java index c454f97..c00239f 100644 --- a/src/main/java/com/bladecoder/ink/runtime/ListDefinitionsOrigin.java +++ b/src/main/java/com/bladecoder/ink/runtime/ListDefinitionsOrigin.java @@ -45,7 +45,7 @@ public List getLists() { ListValue findSingleItemListWithName(String name) { ListValue val = null; - val = allUnambiguousListValueCache.get(name); + if (name != null && !name.trim().isEmpty()) val = allUnambiguousListValueCache.get(name); return val; } diff --git a/src/main/java/com/bladecoder/ink/runtime/NativeFunctionCall.java b/src/main/java/com/bladecoder/ink/runtime/NativeFunctionCall.java index ebdd1a1..f91af2c 100644 --- a/src/main/java/com/bladecoder/ink/runtime/NativeFunctionCall.java +++ b/src/main/java/com/bladecoder/ink/runtime/NativeFunctionCall.java @@ -670,8 +670,8 @@ public RTObject call(List parameters) throws Exception { for (RTObject p : parameters) { if (p instanceof Void) - throw new StoryException( - "Attempting to perform operation on a void value. Did you forget to 'return' a value from a function you called here?"); + throw new StoryException("Attempting to perform " + this.name + + " on a void value. Did you forget to 'return' a value from a function you called here?"); if (p instanceof ListValue) hasList = true; } diff --git a/src/main/java/com/bladecoder/ink/runtime/SimpleJson.java b/src/main/java/com/bladecoder/ink/runtime/SimpleJson.java index b7195a1..a09f94c 100644 --- a/src/main/java/com/bladecoder/ink/runtime/SimpleJson.java +++ b/src/main/java/com/bladecoder/ink/runtime/SimpleJson.java @@ -268,6 +268,16 @@ public void writeObject(InnerWriter inner) throws Exception { writeObjectEnd(); } + public void clear() { + StringWriter stringWriter = writer instanceof StringWriter ? (StringWriter) writer : null; + if (stringWriter == null) { + throw new UnsupportedOperationException( + "Writer.Clear() is only supported for the StringWriter variant, not for streams"); + } + + stringWriter.getBuffer().setLength(0); + } + public void writeObjectStart() throws Exception { startNewObject(true); stateStack.push(new StateElement(State.Object, 0)); @@ -278,6 +288,7 @@ public void writeObjectEnd() throws Exception { Assert(getState() == State.Object); writer.write("}"); stateStack.pop(); + if (getState() == State.None) writer.flush(); } public void writeProperty(String name, InnerWriter inner) throws Exception { diff --git a/src/main/java/com/bladecoder/ink/runtime/Story.java b/src/main/java/com/bladecoder/ink/runtime/Story.java index 6af48d1..447d4db 100644 --- a/src/main/java/com/bladecoder/ink/runtime/Story.java +++ b/src/main/java/com/bladecoder/ink/runtime/Story.java @@ -505,6 +505,36 @@ void callExternalFunction(String funcName, int numberOfArguments) throws Excepti funcDef = externals.get(funcName); + if (funcDef != null && funcDef.lookaheadSafe && state.inStringEvaluation()) { + // 16th Jan 2023: Example ink that was failing: + // + // A line above + // ~ temp text = "{theFunc()}" + // {text} + // + // === function theFunc() + // { external(): + // Boom + // } + // + // EXTERNAL external() + // + // What was happening: The external() call would exit out early due to + // _stateSnapshotAtLastNewline having a value, leaving the evaluation stack + // without a return value on it. When the if-statement tried to pop a value, + // the evaluation stack would be empty, and there would be an exception. + // + // The snapshot rewinding code is only designed to work when outside of + // string generation code (there's a check for that in the snapshot rewinding code), + // hence these things are incompatible, you can't have unsafe functions that + // cause snapshot rewinding in the middle of string generation. + // + error("External function " + funcName + + " could not be called because 1) it wasn't marked as lookaheadSafe when BindExternalFunction was called and 2) the story is in the middle of string generation, either because choice text is being generated, or because you have ink like \"hello {func()}\". You can work around this by generating the result of your function into a temporary variable before the string or choice gets generated: ~ temp x = " + + funcName + "()"); + return; + } + // Should this function break glue? Abort run if we've already seen a newline. // Set a bool to tell it to restore the snapshot at the end of this instruction. if (funcDef != null && !funcDef.lookaheadSafe && stateSnapshotAtLastNewline != null) { @@ -801,7 +831,9 @@ void continueInternal(float millisecsLimitAsync) throws Exception { // 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.getVariablesState().setbatchObservingVariableChanges(true); + if (recursiveContinueCount == 1) state.getVariablesState().startVariableObservation(); + } else if (asyncContinueActive && !isAsyncTimeLimited) { + asyncContinueActive = false; } // Start timing @@ -830,6 +862,8 @@ void continueInternal(float millisecsLimitAsync) throws Exception { durationStopwatch.stop(); + HashMap changedVariablesToObserve = null; + // 4 outcomes: // - got newline (so finished this line of text) // - can't continue (e.g. choices or ending) @@ -863,7 +897,8 @@ else if (!state.getCallStack().canPop()) state.setDidSafeExit(false); sawLookaheadUnsafeFunctionAfterNewline = false; - if (recursiveContinueCount == 1) state.getVariablesState().setbatchObservingVariableChanges(false); + if (recursiveContinueCount == 1) + changedVariablesToObserve = state.getVariablesState().completeVariableObservation(); asyncContinueActive = false; } @@ -925,6 +960,11 @@ else if (!state.getCallStack().canPop()) throw new StoryException(sb.toString()); } } + + // Send out variable observation events at the last second, since it might trigger new ink to be run + if (changedVariablesToObserve != null && changedVariablesToObserve.size() > 0) { + state.getVariablesState().notifyObservers(changedVariablesToObserve); + } } boolean continueSingleStep() throws Exception { @@ -2802,7 +2842,7 @@ public Object evaluateFunction(String functionName, StringBuilder textOutput, Ob // - _state (current, being patched) void stateSnapshot() { stateSnapshotAtLastNewline = state; - state = state.copyAndStartPatching(); + state = state.copyAndStartPatching(false); } void restoreStateSnapshot() { @@ -2853,7 +2893,7 @@ public StoryState copyStateForBackgroundThreadSave() throws Exception { throw new Exception( "Story is already in background saving mode, can't call CopyStateForBackgroundThreadSave again!"); StoryState stateToSave = state; - state = state.copyAndStartPatching(); + state = state.copyAndStartPatching(true); asyncSaving = true; return stateToSave; } diff --git a/src/main/java/com/bladecoder/ink/runtime/StoryState.java b/src/main/java/com/bladecoder/ink/runtime/StoryState.java index b8d0363..0dddaed 100644 --- a/src/main/java/com/bladecoder/ink/runtime/StoryState.java +++ b/src/main/java/com/bladecoder/ink/runtime/StoryState.java @@ -113,7 +113,7 @@ void addError(String message, boolean isWarning) { // RTObjects 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..?? - StoryState copyAndStartPatching() { + StoryState copyAndStartPatching(boolean forBackgroundSave) { StoryState copy = new StoryState(story); copy.patch = new StatePatch(patch); @@ -123,10 +123,20 @@ StoryState copyAndStartPatching() { // _namedFlows copy.currentFlow.name = currentFlow.name; copy.currentFlow.callStack = new CallStack(currentFlow.callStack); - copy.currentFlow.currentChoices.addAll(currentFlow.currentChoices); copy.currentFlow.outputStream.addAll(currentFlow.outputStream); copy.outputStreamDirty(); + // When background saving we need to make copies of choices since they each have + // a snapshot of the thread at the time of generation since the game could progress + // significantly and threads modified during the save process. + // However, when doing internal saving and restoring of snapshots this isn't an issue, + // and we can simply ref-copy the choices with their existing threads. + if (forBackgroundSave) { + for (Choice choice : currentFlow.currentChoices) copy.currentFlow.currentChoices.add(choice.clone()); + } else { + copy.currentFlow.currentChoices.addAll(currentFlow.currentChoices); + } + // The copy of the state has its own copy of the named flows dictionary, // except with the current flow replaced with the copy above // (Assuming we're in multi-flow mode at all. If we're not then @@ -312,6 +322,17 @@ void popCallstack(PushPopType popType) throws Exception { getCallStack().pop(popType); } + /** + * Get the previous state of currentPathString, which can be helpful + * for finding out where the story was before it ended (when the path + * string becomes null) + */ + public String previousPathString() { + Pointer pointer = getPreviousPointer(); + if (pointer.isNull()) return null; + else return pointer.getPath().toString(); + } + Pointer getCurrentPointer() { return getCallStack().getCurrentElement().currentPointer; } diff --git a/src/main/java/com/bladecoder/ink/runtime/VariablesState.java b/src/main/java/com/bladecoder/ink/runtime/VariablesState.java index d05305a..d58af59 100644 --- a/src/main/java/com/bladecoder/ink/runtime/VariablesState.java +++ b/src/main/java/com/bladecoder/ink/runtime/VariablesState.java @@ -68,7 +68,7 @@ public void assign(VariableAssignment varAss, RTObject value) throws Exception { // Then assign to the variable that the pointer is pointing to by // name. // De-reference variable reference to point to - VariablePointerValue existingPointer = null; + VariablePointerValue existingPointer; do { existingPointer = getRawVariableWithName(name, contextIndex) instanceof VariablePointerValue ? (VariablePointerValue) getRawVariableWithName(name, contextIndex) @@ -134,7 +134,7 @@ void writeJson(SimpleJson.Writer writer) throws Exception { if (dontSaveDefaultValues) { // Don't write out values that are the same as the default global values - RTObject defaultVal = defaultGlobalVariables.get(name); + RTObject defaultVal = defaultGlobalVariables != null ? defaultGlobalVariables.get(name) : null; if (defaultVal != null) { if (runtimeObjectsEqual(val, defaultVal)) continue; } @@ -199,8 +199,41 @@ public Object get(String variableName) { } else return null; } - public boolean getbatchObservingVariableChanges() { - return batchObservingVariableChanges; + public void startVariableObservation() { + batchObservingVariableChanges = true; + changedVariablesForBatchObs = new HashSet<>(); + } + + public HashMap completeVariableObservation() { + batchObservingVariableChanges = false; + + HashMap changedVars = new HashMap<>(); + if (changedVariablesForBatchObs != null) { + for (String variableName : changedVariablesForBatchObs) { + RTObject currentValue = globalVariables.get(variableName); + changedVars.put(variableName, currentValue); + } + } + + // Patch may still be active - e.g. if we were in the middle of a background save + if (patch != null) { + for (String variableName : patch.getChangedVariables()) { + RTObject patchedVal = patch.getGlobal(variableName); + + if (patchedVal != null) { + changedVars.put(variableName, patchedVal); + } + } + } + + changedVariablesForBatchObs = null; + return changedVars; + } + + public void notifyObservers(HashMap changedVars) throws Exception { + for (Entry varToVal : changedVars.entrySet()) { + variableChangedEvent.variableStateDidChangeEvent(varToVal.getKey(), varToVal.getValue()); + } } // Make copy of the variable pointer so we're not using the value direct @@ -319,24 +352,6 @@ public void set(String variableName, Object value) throws Exception { setGlobal(variableName, val); } - public void setbatchObservingVariableChanges(boolean value) throws Exception { - batchObservingVariableChanges = value; - if (value) { - changedVariablesForBatchObs = new HashSet<>(); - } else { - // Finished observing variables in a batch - now send - // notifications for changed variables all in one go. - if (changedVariablesForBatchObs != null) { - for (String variableName : changedVariablesForBatchObs) { - RTObject currentValue = globalVariables.get(variableName); - getVariableChangedEvent().variableStateDidChangeEvent(variableName, currentValue); - } - } - - changedVariablesForBatchObs = null; - } - } - void retainListOriginsForAssignment(RTObject oldValue, RTObject newValue) { ListValue oldList = null; @@ -364,7 +379,7 @@ void setGlobal(String variableName, RTObject value) throws Exception { if (getVariableChangedEvent() != null && !value.equals(oldValue)) { - if (getbatchObservingVariableChanges()) { + if (batchObservingVariableChanges) { if (patch != null) patch.addChangedVariable(variableName); else if (changedVariablesForBatchObs != null) changedVariablesForBatchObs.add(variableName); } else { diff --git a/src/test/java/com/bladecoder/ink/runtime/test/TagSpecTest.java b/src/test/java/com/bladecoder/ink/runtime/test/TagSpecTest.java index e553125..8ed7ed8 100644 --- a/src/test/java/com/bladecoder/ink/runtime/test/TagSpecTest.java +++ b/src/test/java/com/bladecoder/ink/runtime/test/TagSpecTest.java @@ -90,6 +90,5 @@ public void testTagsInLines() throws Exception { Assert.assertEquals("í\n", story.Continue()); Assert.assertEquals("a\n", story.Continue()); - } }