diff --git a/README.md b/README.md index 9be1d448..b8c8f4e7 100644 --- a/README.md +++ b/README.md @@ -224,3 +224,4 @@ import { Identifier } from 'inkjs/compiler/Parser/ParsedHierarchy/Identifier'; | 0.9.0 | 1.11.0 | 19 | | 1.0.0 | 2.0.0 - 2.1.0 | 20 | | 1.1.1 | 2.2.* | 21 | +| 1.2.0 | 2.3.0 | | diff --git a/package.json b/package.json index 8eadde59..6ad49c24 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "inkjs", - "version": "2.2.5", + "version": "2.3.0", "description": "A javascript port of inkle's ink scripting language (http://www.inklestudios.com/ink/)", "type": "commonjs", "main": "dist/ink-full.js", diff --git a/src/compiler/Parser/InkParser.ts b/src/compiler/Parser/InkParser.ts index 95bb7b4e..5a8e8bc7 100644 --- a/src/compiler/Parser/InkParser.ts +++ b/src/compiler/Parser/InkParser.ts @@ -349,6 +349,15 @@ export class InkParser extends StringParser { new CharacterSet() ); + public static readonly Latin1Supplement: CharacterRange = + CharacterRange.Define("\u0080", "\u00FF", new CharacterSet()); + + public static readonly Chinese: CharacterRange = CharacterRange.Define( + "\u4E00", + "\u9FFF", + new CharacterSet() + ); + private readonly ExtendIdentifierCharacterRanges = ( identifierCharSet: CharacterSet ): void => { @@ -376,6 +385,8 @@ export class InkParser extends StringParser { InkParser.Greek, InkParser.Hebrew, InkParser.Korean, + InkParser.Latin1Supplement, + InkParser.Chinese, ]; /** @@ -415,6 +426,9 @@ export class InkParser extends StringParser { this.Whitespace(); + // Allow optional newline right after a choice name + if (optionalName != null) this.Newline(); + // Optional condition for whether the choice should be shown to the player const conditionExpr: Expression = this.Parse( this.ChoiceCondition diff --git a/src/engine/CallStack.ts b/src/engine/CallStack.ts index 818faf9d..bdeacee3 100644 --- a/src/engine/CallStack.ts +++ b/src/engine/CallStack.ts @@ -179,6 +179,7 @@ export class CallStack { name: string | null, contextIndex: number = -1 ) { + // contextIndex 0 means global, so index is actually 1-based if (contextIndex == -1) contextIndex = this.currentElementIndex + 1; let contextElement = this.callStack[contextIndex - 1]; @@ -359,16 +360,21 @@ export namespace CallStack { ". Has the story changed since this save data was created?" ); else if (threadPointerResult.approximate) { - if (pointer.container === null) { - return throwNullException("pointer.container"); + 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.path.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?" + ); } - 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?" - ); } } diff --git a/src/engine/Choice.ts b/src/engine/Choice.ts index d29a9e96..662ae268 100644 --- a/src/engine/Choice.ts +++ b/src/engine/Choice.ts @@ -21,4 +21,18 @@ export class Choice extends InkObject { set pathStringOnChoice(value: string) { this.targetPath = new Path(value); } + + public Clone() { + let copy = new Choice(); + copy.text = this.text; + copy.sourcePath = this.sourcePath; + copy.index = this.index; + copy.targetPath = this.targetPath; + copy.originalThreadIndex = this.originalThreadIndex; + copy.isInvisibleDefault = this.isInvisibleDefault; + if (this.threadAtGeneration !== null) + copy.threadAtGeneration = this.threadAtGeneration.Copy(); + + return copy; + } } diff --git a/src/engine/Container.ts b/src/engine/Container.ts index 25b36c21..80672e8d 100644 --- a/src/engine/Container.ts +++ b/src/engine/Container.ts @@ -168,13 +168,24 @@ export class Container extends InkObject implements INamedContent { let foundObj: InkObject | null = currentContainer.ContentWithPathComponent(comp); + // Couldn't resolve entire path? if (foundObj == null) { result.approximate = true; 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. + const nextContainer: Container | null = asOrNull(foundObj, Container); + if (i < partialPathLength - 1 && nextContainer == null) { + result.approximate = true; + break; + } + currentObj = foundObj; - currentContainer = asOrNull(foundObj, Container); + currentContainer = nextContainer; } result.obj = currentObj; diff --git a/src/engine/InkList.ts b/src/engine/InkList.ts index abaa2d1e..1a34cb46 100644 --- a/src/engine/InkList.ts +++ b/src/engine/InkList.ts @@ -170,6 +170,7 @@ export class InkList extends Map { } public static FromString(myListItem: string, originStory: Story) { + if (myListItem == null || myListItem == "") return new InkList(); let listValue = originStory.listDefinitions?.FindSingleItemListWithName(myListItem); if (listValue) { @@ -186,7 +187,10 @@ export class InkList extends Map { } } - public AddItem(itemOrItemName: InkListItem | string | null) { + public AddItem( + itemOrItemName: InkListItem | string | null, + storyObject: Story | null = null + ) { if (itemOrItemName instanceof InkListItem) { let item = itemOrItemName; @@ -216,8 +220,9 @@ export class InkList extends Map { throw new Error( "Failed to add item to list because the item was from a new list definition that wasn't previously known to this list. Only items from previously known lists can be used, so that the int value can be found." ); - } else { - let itemName = itemOrItemName as string | null; + } else if (itemOrItemName !== null) { + //itemOrItemName is a string + let itemName = itemOrItemName as string; let foundListDef: ListDefinition | null = null; @@ -242,16 +247,23 @@ export class InkList extends Map { } } - if (foundListDef == null) - throw new Error( - "Could not add the item " + - itemName + - " to this list because it isn't known to any list definitions previously associated with this list." - ); - - let item = new InkListItem(foundListDef.name, itemName); - let itemVal = foundListDef.ValueForItem(item); - this.Add(item, itemVal); + if (foundListDef == null) { + if (storyObject == null) { + throw new Error( + "Could not add the item " + + itemName + + " to this list because it isn't known to any list definitions previously associated with this list." + ); + } else { + let newItem = InkList.FromString(itemName, storyObject) + .orderedItems[0]; + this.Add(newItem.Key, newItem.Value); + } + } else { + let item = new InkListItem(foundListDef.name, itemName); + let itemVal = foundListDef.ValueForItem(item); + this.Add(item, itemVal); + } } } public ContainsItemNamed(itemName: string | null) { @@ -519,6 +531,14 @@ export class InkList extends Map { return ordered; } + + get singleItem(): InkListItem | null { + for (let item of this.orderedItems) { + return item.Key; + } + return null; + } + public toString() { let ordered = this.orderedItems; diff --git a/src/engine/JsonSerialisation.ts b/src/engine/JsonSerialisation.ts index 97356b95..d6a8aa3b 100644 --- a/src/engine/JsonSerialisation.ts +++ b/src/engine/JsonSerialisation.ts @@ -580,10 +580,16 @@ export class JsonSerialisation { choice.sourcePath = jObj["originalChoicePath"].toString(); choice.originalThreadIndex = parseInt(jObj["originalThreadIndex"]); choice.pathStringOnChoice = jObj["targetPath"].toString(); + choice.tags = this.JArrayToTags(jObj); + return choice; + } + + public static JArrayToTags(jObj: Record) { if (jObj["tags"]) { - choice.tags = jObj["tags"]; + return jObj["tags"]; + } else { + return null; } - return choice; } public static WriteChoice(writer: SimpleJson.Writer, choice: Choice) { @@ -593,20 +599,22 @@ export class JsonSerialisation { writer.WriteProperty("originalChoicePath", choice.sourcePath); writer.WriteIntProperty("originalThreadIndex", choice.originalThreadIndex); writer.WriteProperty("targetPath", choice.pathStringOnChoice); - if (choice.tags) { - writer.WriteProperty("tags", (w) => { - w.WriteArrayStart(); - for (const tag of choice.tags!) { - w.WriteStringStart(); - w.WriteStringInner(tag); - w.WriteStringEnd(); - } - w.WriteArrayEnd(); - }); - } + this.WriteChoiceTags(writer, choice); writer.WriteObjectEnd(); } + public static WriteChoiceTags(writer: SimpleJson.Writer, choice: Choice) { + if (choice.tags && choice.tags.length > 0) { + writer.WritePropertyStart("tags"); + writer.WriteArrayStart(); + for (const tag of choice.tags!) { + writer.Write(tag); + } + writer.WriteArrayEnd(); + writer.WritePropertyEnd(); + } + } + public static WriteInkList(writer: SimpleJson.Writer, listVal: ListValue) { let rawList = listVal.value; if (rawList === null) { diff --git a/src/engine/NativeFunctionCall.ts b/src/engine/NativeFunctionCall.ts index 5eed05a3..92bea48b 100644 --- a/src/engine/NativeFunctionCall.ts +++ b/src/engine/NativeFunctionCall.ts @@ -94,7 +94,9 @@ export class NativeFunctionCall extends InkObject { for (let p of 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?' + "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/engine/Story.ts b/src/engine/Story.ts index ea74618d..19a83cf4 100644 --- a/src/engine/Story.ts +++ b/src/engine/Story.ts @@ -370,7 +370,9 @@ export class Story extends InkObject { this._state.ResetOutput(); if (this._recursiveContinueCount == 1) - this._state.variablesState.batchObservingVariableChanges = true; + this._state.variablesState.StartVariableObservation(); + } else if (this._asyncContinueActive && !isAsyncTimeLimited) { + this._asyncContinueActive = false; } let durationStopwatch = new Stopwatch(); @@ -400,6 +402,8 @@ export class Story extends InkObject { durationStopwatch.Stop(); + let changedVariablesToObserve: Map | null = null; + if (outputStreamEndsInNewline || !this.canContinue) { if (this._stateSnapshotAtLastNewline !== null) { this.RestoreStateSnapshot(); @@ -439,7 +443,8 @@ export class Story extends InkObject { this._sawLookaheadUnsafeFunctionAfterNewline = false; if (this._recursiveContinueCount == 1) - this._state.variablesState.batchObservingVariableChanges = false; + changedVariablesToObserve = + this._state.variablesState.CompleteVariableObservation(); this._asyncContinueActive = false; if (this.onDidContinue !== null) this.onDidContinue(); @@ -494,6 +499,12 @@ export class Story extends InkObject { throw new StoryException(sb.toString()); } } + if ( + changedVariablesToObserve != null && + Object.keys(changedVariablesToObserve).length > 0 + ) { + this._state.variablesState.NotifyObservers(changedVariablesToObserve); + } } public ContinueSingleStep() { @@ -664,7 +675,7 @@ export class Story extends InkObject { public StateSnapshot() { this._stateSnapshotAtLastNewline = this._state; - this._state = this._state.CopyAndStartPatching(); + this._state = this._state.CopyAndStartPatching(false); } public RestoreStateSnapshot() { @@ -696,7 +707,7 @@ export class Story extends InkObject { ); let stateToSave = this._state; - this._state = this._state.CopyAndStartPatching(); + this._state = this._state.CopyAndStartPatching(true); this._asyncSaving = true; return stateToSave; } @@ -1836,6 +1847,20 @@ export class Story extends InkObject { let foundExternal = typeof funcDef !== "undefined"; + if ( + foundExternal && + !funcDef!.lookAheadSafe && + this._state.inStringEvaluation + ) { + this.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 + + "()" + ); + } + if ( foundExternal && !funcDef!.lookAheadSafe && diff --git a/src/engine/StoryState.ts b/src/engine/StoryState.ts index 7ac8e01b..c71e612f 100644 --- a/src/engine/StoryState.ts +++ b/src/engine/StoryState.ts @@ -227,6 +227,18 @@ export class StoryState { } } + get previousPathString() { + let pointer = this.previousPointer; + if (pointer.isNull) { + return null; + } else { + if (pointer.path === null) { + return throwNullException("previousPointer.path"); + } + return pointer.path.toString(); + } + } + get currentPointer() { return this.callStack.currentElement.currentPointer.copy(); } @@ -491,17 +503,32 @@ export class StoryState { this._aliveFlowNamesDirty = true; } - public CopyAndStartPatching() { + public CopyAndStartPatching(forBackgroundSave: boolean) { let copy = new StoryState(this.story); copy._patch = new StatePatch(this._patch); copy._currentFlow.name = this._currentFlow.name; copy._currentFlow.callStack = new CallStack(this._currentFlow.callStack); - copy._currentFlow.currentChoices.push(...this._currentFlow.currentChoices); copy._currentFlow.outputStream.push(...this._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 (let choice of this._currentFlow.currentChoices) { + copy._currentFlow.currentChoices.push(choice.Clone()); + } + } else { + copy._currentFlow.currentChoices.push( + ...this._currentFlow.currentChoices + ); + } + if (this._namedFlows !== null) { copy._namedFlows = new Map(); for (let [namedFlowKey, namedFlowValue] of this._namedFlows) { diff --git a/src/engine/VariablesState.ts b/src/engine/VariablesState.ts index 871a9580..d75e5caa 100644 --- a/src/engine/VariablesState.ts +++ b/src/engine/VariablesState.ts @@ -47,27 +47,35 @@ export class VariablesState extends VariablesStateAccessor< public patch: StatePatch | null = null; - get batchObservingVariableChanges() { - return this._batchObservingVariableChanges; + public StartVariableObservation() { + this._batchObservingVariableChanges = true; + this._changedVariablesForBatchObs = new Set(); } - set batchObservingVariableChanges(value: boolean) { - this._batchObservingVariableChanges = value; - if (value) { - this._changedVariablesForBatchObs = new Set(); - } else { - if (this._changedVariablesForBatchObs != null) { - for (let variableName of this._changedVariablesForBatchObs) { - let currentValue = this._globalVariables.get(variableName); - if (!currentValue) { - throwNullException("currentValue"); - } else { - this.variableChangedEvent(variableName, currentValue); - } - } - this._changedVariablesForBatchObs = null; + public CompleteVariableObservation(): Map { + this._batchObservingVariableChanges = false; + let changedVars = new Map(); + if (this._changedVariablesForBatchObs != null) { + for (let variableName of this._changedVariablesForBatchObs) { + let currentValue = this._globalVariables.get(variableName) as InkObject; + this.variableChangedEvent(variableName, currentValue); + } + } + // Patch may still be active - e.g. if we were in the middle of a background save + if (this.patch != null) { + for (let variableName of this.patch.changedVariables) { + let patchedVal = this.patch.TryGetGlobal(variableName, null); + if (patchedVal.exists) changedVars.set(variableName, patchedVal); } } + this._changedVariablesForBatchObs = null; + return changedVars; + } + + public NotifyObservers(changedVars: Map) { + for (const [key, value] of changedVars) { + this.variableChangedEvent(key, value); + } } get callStack() { @@ -77,8 +85,6 @@ export class VariablesState extends VariablesStateAccessor< this._callStack = callStack; } - private _batchObservingVariableChanges: boolean = false; - // the original code uses a magic getter and setter for global variables, // allowing things like variableState['varname]. This is not quite possible // in js without a Proxy, so it is replaced with this $ function. @@ -429,7 +435,7 @@ export class VariablesState extends VariablesStateAccessor< oldValue !== null && value !== oldValue.result ) { - if (this.batchObservingVariableChanges) { + if (this._batchObservingVariableChanges) { if (this._changedVariablesForBatchObs === null) { return throwNullException("this._changedVariablesForBatchObs"); } @@ -495,4 +501,6 @@ export class VariablesState extends VariablesStateAccessor< private _callStack: CallStack; private _changedVariablesForBatchObs: Set | null = new Set(); private _listDefsOrigin: ListDefinitionsOrigin | null; + + private _batchObservingVariableChanges: boolean = false; } diff --git a/src/tests/inkfiles/original/choices/newline_after_choice.ink b/src/tests/inkfiles/original/choices/newline_after_choice.ink new file mode 100644 index 00000000..a87b3dc4 --- /dev/null +++ b/src/tests/inkfiles/original/choices/newline_after_choice.ink @@ -0,0 +1,2 @@ +* (say_something_interesting_about_bricklaying) + I did have one interesting fact about bricklaying, if you don't mind me spending taking a fair bit of time to lay the groundwork for it. \ No newline at end of file diff --git a/src/tests/inkfiles/original/lists/list_range.ink b/src/tests/inkfiles/original/lists/list_range.ink index 124bfed0..218c08bc 100644 --- a/src/tests/inkfiles/original/lists/list_range.ink +++ b/src/tests/inkfiles/original/lists/list_range.ink @@ -6,6 +6,7 @@ VAR all = () {all} {LIST_RANGE(all, 2, 3)} {LIST_RANGE(LIST_ALL(Numbers), Two, Six)} +{LIST_RANGE(LIST_ALL(Numbers), Currency, Three)} {LIST_RANGE(LIST_ALL(Numbers), 2, Four)} // mix int and list {LIST_RANGE(LIST_ALL(Numbers), Two, 5)} // mix list and int {LIST_RANGE((Pizza, Pasta), -1, 100)} // allow out of range diff --git a/src/tests/specs/ink/Choices.spec.ts b/src/tests/specs/ink/Choices.spec.ts index 41c5fd0f..859b59e0 100644 --- a/src/tests/specs/ink/Choices.spec.ts +++ b/src/tests/specs/ink/Choices.spec.ts @@ -289,4 +289,14 @@ describe("Choices", () => { expect(context.story.currentChoices[0].text).toEqual("choice"); expect(context.story.currentChoices[0].tags).toEqual(["tag aaabbb"]); }); + + it("tests newline after choice name", () => { + compileStory("newline_after_choice", true); + context.story.Continue(); + + expect(context.story.currentChoices.length).toBe(1); + expect(context.story.currentChoices[0].text).toEqual( + "I did have one interesting fact about bricklaying, if you don't mind me spending taking a fair bit of time to lay the groundwork for it." + ); + }); }); diff --git a/src/tests/specs/ink/Lists.spec.ts b/src/tests/specs/ink/Lists.spec.ts index 7d4ca56f..b18b2f70 100644 --- a/src/tests/specs/ink/Lists.spec.ts +++ b/src/tests/specs/ink/Lists.spec.ts @@ -68,6 +68,7 @@ describe("Lists", () => { `Pound, Pizza, Euro, Pasta, Dollar, Curry, Paella Euro, Pasta, Dollar, Curry Two, Three, Four, Five, Six +One, Two, Three Two, Three, Four Two, Three, Four, Five Pizza, Pasta