import {CallStack} from './CallStack'; import {VariablesState} from './VariablesState'; import {ValueType, Value, StringValue, ListValue} from './Value'; import {PushPopType} from './PushPop'; import {Tag} from './Tag'; import {Glue} from './Glue'; import {Path} from './Path'; import {ControlCommand} from './ControlCommand'; import {StoryException} from './StoryException'; import {StringBuilder} from './StringBuilder'; import {JsonSerialisation} from './JsonSerialisation'; import {PRNG} from './PRNG'; import {Void} from './Void'; import {Pointer} from './Pointer'; import {tryGetValueFromMap} from './TryGetResult'; import {Choice} from './Choice'; import {asOrNull, asOrThrows, nullIfUndefined} from './TypeAssertion'; import {JObject} from './JObject'; import {Debug} from './Debug'; import {Container} from './Container'; import {InkObject} from './Object'; import { throwNullException } from './NullException'; import { Story } from './Story'; export class StoryState{ public readonly kInkSaveStateVersion = 8; public readonly kMinCompatibleLoadVersion = 8; public ToJson(indented: boolean = false){ return JSON.stringify(this.jsonToken, null, (indented) ? 2 : 0); } public toJson(indented: boolean = false){ return this.ToJson(indented); } public LoadJson(json: string){ this.jsonToken = JSON.parse(json); } public VisitCountAtPathString(pathString: string){ let visitCountOut = tryGetValueFromMap(this.visitCounts, pathString, null); if (visitCountOut.exists) return visitCountOut.result; return 0; } get callstackDepth(){ return this.callStack.depth; } get outputStream(){ return this._outputStream; } get currentChoices(){ // If we can continue generating text content rather than choices, // then we reflect the choice list as being empty, since choices // should always come at the end. if ( this.canContinue ) return []; return this._currentChoices; } get generatedChoices(){ return this._currentChoices; } get currentErrors(){ return this._currentErrors; } private _currentErrors: string[] | null = null; get currentWarnings(){ return this._currentWarnings; } private _currentWarnings: string[] | null = null; get variablesState(){ return this._variablesState; } private _variablesState: VariablesState; public callStack: CallStack; get evaluationStack(){ return this._evaluationStack; } private _evaluationStack: InkObject[]; public divertedPointer: Pointer = Pointer.Null; get visitCounts(){ return this._visitCounts; } private _visitCounts: Map; get turnIndices(){ return this._turnIndices; } private _turnIndices: Map; get currentTurnIndex(){ return this._currentTurnIndex; } private _currentTurnIndex: number = 0; public storySeed: number = 0; public previousRandom: number = 0; public didSafeExit: boolean = false; public story: Story; get currentPathString(){ let pointer = this.currentPointer; if (pointer.isNull) { return null; } else { if (pointer.path === null) { return throwNullException('pointer.path'); } return pointer.path.toString(); } } get currentPointer(){ return this.callStack.currentElement.currentPointer.copy(); } set currentPointer(value){ this.callStack.currentElement.currentPointer = value.copy(); } get previousPointer(){ return this.callStack.currentThread.previousPointer.copy(); } set previousPointer(value){ this.callStack.currentThread.previousPointer = value.copy(); } get canContinue(){ return !this.currentPointer.isNull && !this.hasError; } get hasError(){ return this.currentErrors != null && this.currentErrors.length > 0; } get hasWarning(){ return this.currentWarnings != null && this.currentWarnings.length > 0; } get currentText(){ if( this._outputStreamTextDirty ) { let sb = new StringBuilder(); for (let outputObj of this._outputStream) { // var textContent = outputObj as StringValue; let textContent = asOrNull(outputObj, StringValue); if (textContent !== null) { sb.Append(textContent.value); } } this._currentText = this.CleanOutputWhitespace(sb.toString()); this._outputStreamTextDirty = false; } return this._currentText; } private _currentText: string | null = null; public CleanOutputWhitespace(str: string){ let sb = new StringBuilder(); let currentWhitespaceStart = -1; let startOfLine = 0; for (let i = 0; i < str.length; i++) { let c = str.charAt(i); let 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(); } get currentTags(){ if( this._outputStreamTagsDirty ) { this._currentTags = []; for(let outputObj of this._outputStream) { // var tag = outputObj as Tag; let tag = asOrNull(outputObj, Tag); if (tag !== null) { this._currentTags.push(tag.text); } } this._outputStreamTagsDirty = false; } return this._currentTags; } private _currentTags: string[] | null = null; get inExpressionEvaluation(){ return this.callStack.currentElement.inExpressionEvaluation; } set inExpressionEvaluation(value){ this.callStack.currentElement.inExpressionEvaluation = value; } constructor(story: Story){ this.story = story; this._outputStream = []; this.OutputStreamDirty(); this._evaluationStack = []; this.callStack = new CallStack(story); this._variablesState = new VariablesState(this.callStack, story.listDefinitions); this._visitCounts = new Map(); this._turnIndices = new Map(); this._currentTurnIndex = -1; let timeSeed = (new Date()).getTime(); this.storySeed = (new PRNG(timeSeed)).next() % 100; this.previousRandom = 0; this._currentChoices = []; this.GoToStart(); } public GoToStart(){ this.callStack.currentElement.currentPointer = Pointer.StartOf(this.story.mainContentContainer); } public Copy(){ let copy = new StoryState(this.story); copy.outputStream.push.apply(copy.outputStream, this._outputStream); this.OutputStreamDirty(); copy._currentChoices.push.apply(copy._currentChoices, this._currentChoices); if (this.hasError) { copy._currentErrors = []; copy._currentErrors.push.apply(copy._currentErrors, this.currentErrors || []); } if (this.hasWarning) { copy._currentWarnings = []; copy._currentWarnings.push.apply(copy._currentWarnings, this.currentWarnings || []); } copy.callStack = new CallStack(this.callStack); copy._variablesState = new VariablesState(copy.callStack, this.story.listDefinitions); copy.variablesState.CopyFrom(this.variablesState); copy.evaluationStack.push.apply(copy.evaluationStack, this.evaluationStack); if (!this.divertedPointer.isNull) copy.divertedPointer = this.divertedPointer.copy(); copy.previousPointer = this.previousPointer.copy(); copy._visitCounts = new Map(this.visitCounts); copy._turnIndices = new Map(this.turnIndices); copy._currentTurnIndex = this.currentTurnIndex; copy.storySeed = this.storySeed; copy.previousRandom = this.previousRandom; copy.didSafeExit = this.didSafeExit; return copy; } get jsonToken(){ let obj: JObject = {}; let choiceThreads: JObject | undefined; for (let c of this._currentChoices) { if (c.threadAtGeneration === null) { return throwNullException('c.threadAtGeneration'); } c.originalThreadIndex = c.threadAtGeneration.threadIndex; if( this.callStack.ThreadWithIndex(c.originalThreadIndex) == null ) { if( choiceThreads == null ) choiceThreads = new Map(); choiceThreads[c.originalThreadIndex.toString()] = c.threadAtGeneration.jsonToken; } } if (choiceThreads != null) obj['choiceThreads'] = choiceThreads; obj['callstackThreads'] = this.callStack.GetJsonToken(); obj['variablesState'] = this.variablesState.jsonToken; obj['evalStack'] = JsonSerialisation.ListToJArray(this.evaluationStack); obj['outputStream'] = JsonSerialisation.ListToJArray(this._outputStream); obj['currentChoices'] = JsonSerialisation.ListToJArray(this._currentChoices); if(!this.divertedPointer.isNull) { if (this.divertedPointer.path === null) { return throwNullException('this.divertedPointer.path'); } obj['currentDivertTarget'] = this.divertedPointer.path.componentsString; } obj['visitCounts'] = JsonSerialisation.IntDictionaryToJObject(this.visitCounts); obj['turnIndices'] = JsonSerialisation.IntDictionaryToJObject(this.turnIndices); obj['turnIdx'] = this.currentTurnIndex; obj['storySeed'] = this.storySeed; obj['previousRandom'] = this.previousRandom; obj['inkSaveVersion'] = this.kInkSaveStateVersion; // Not using this right now, but could do in future. obj['inkFormatVersion'] = this.story.inkVersionCurrent; return obj; } set jsonToken(value: JObject){ let jObject = value; let jSaveVersion = jObject['inkSaveVersion']; if (jSaveVersion == null) { throw new StoryException("ink save format incorrect, can't load."); } else if (parseInt(jSaveVersion) < this.kMinCompatibleLoadVersion) { throw new StoryException("Ink save format isn't compatible with the current version (saw '"+jSaveVersion+"', but minimum is "+this.kMinCompatibleLoadVersion+"), so can't load."); } this.callStack.SetJsonToken(jObject['callstackThreads'], this.story); this.variablesState.jsonToken = jObject['variablesState']; this._evaluationStack = JsonSerialisation.JArrayToRuntimeObjList(jObject['evalStack']); this._outputStream = JsonSerialisation.JArrayToRuntimeObjList(jObject['outputStream']); this.OutputStreamDirty(); // currentChoices = Json.JArrayToRuntimeObjList((JArray)jObject ["currentChoices"]); this._currentChoices = JsonSerialisation.JArrayToRuntimeObjList(jObject['currentChoices']) as Choice[]; let currentDivertTargetPath = jObject['currentDivertTarget']; if (currentDivertTargetPath != null) { let divertPath = new Path(currentDivertTargetPath.toString()); this.divertedPointer = this.story.PointerAtPath(divertPath); } this._visitCounts = JsonSerialisation.JObjectToIntDictionary(jObject['visitCounts']) as Map; this._turnIndices = JsonSerialisation.JObjectToIntDictionary(jObject['turnIndices']) as Map; this._currentTurnIndex = parseInt(jObject['turnIdx']); this.storySeed = parseInt(jObject['storySeed']); this.previousRandom = parseInt(jObject['previousRandom']); // var jChoiceThreads = jObject["choiceThreads"] as JObject; let jChoiceThreads = jObject['choiceThreads']; for(let c of this._currentChoices) { let foundActiveThread = this.callStack.ThreadWithIndex(c.originalThreadIndex); if( foundActiveThread != null ) { c.threadAtGeneration = foundActiveThread.Copy(); } else { let jSavedChoiceThread = jChoiceThreads[c.originalThreadIndex.toString()]; c.threadAtGeneration = new CallStack.Thread(jSavedChoiceThread, this.story); } } } public ResetErrors(){ this._currentErrors = null; this._currentWarnings = null; } public ResetOutput(objs: InkObject[] | null = null){ this._outputStream.length = 0; if (objs !== null) this._outputStream.push.apply(this._outputStream, objs); this.OutputStreamDirty(); } public PushToOutputStream(obj: InkObject | null){ // var text = obj as StringValue; let text = asOrNull(obj, StringValue); if (text !== null) { let listText = this.TrySplittingHeadTailWhitespace(text); if (listText !== null) { for(let textObj of listText) { this.PushToOutputStreamIndividual(textObj); } this.OutputStreamDirty(); return; } } this.PushToOutputStreamIndividual(obj); this.OutputStreamDirty(); } public PopFromOutputStream(count: number){ this.outputStream.splice(this.outputStream.length - count, count); this.OutputStreamDirty(); } public TrySplittingHeadTailWhitespace(single: StringValue) { let str = single.value; if (str === null) { return throwNullException('single.value'); } let headFirstNewlineIdx = -1; let headLastNewlineIdx = -1; for (let i = 0; i < str.length; ++i) { let c = str[i]; if (c == '\n') { if (headFirstNewlineIdx == -1) headFirstNewlineIdx = i; headLastNewlineIdx = i; } else if (c == ' ' || c == '\t') continue; else break; } let tailLastNewlineIdx = -1; let tailFirstNewlineIdx = -1; for (let i = 0; i < str.length; ++i) { let c = str[i]; if (c == '\n') { if (tailLastNewlineIdx == -1) tailLastNewlineIdx = i; tailFirstNewlineIdx = i; } else if (c == ' ' || c == '\t') continue; else break; } // No splitting to be done? if (headFirstNewlineIdx == -1 && tailLastNewlineIdx == -1) return null; let listTexts: StringValue[] = []; let innerStrStart = 0; let innerStrEnd = str.length; if (headFirstNewlineIdx != -1) { if (headFirstNewlineIdx > 0) { let leadingSpaces = new StringValue(str.substring(0, headFirstNewlineIdx)); listTexts.push(leadingSpaces); } listTexts.push(new StringValue('\n')); innerStrStart = headLastNewlineIdx + 1; } if (tailLastNewlineIdx != -1) { innerStrEnd = tailFirstNewlineIdx; } if (innerStrEnd > innerStrStart) { let innerStrText = str.substring(innerStrStart, innerStrEnd - innerStrStart); listTexts.push(new StringValue(innerStrText)); } if (tailLastNewlineIdx != -1 && tailFirstNewlineIdx > headLastNewlineIdx) { listTexts.push(new StringValue('\n')); if (tailLastNewlineIdx < str.length - 1) { let numSpaces = (str.length - tailLastNewlineIdx) - 1; let trailingSpaces = new StringValue(str.substring(tailLastNewlineIdx + 1, numSpaces)); listTexts.push(trailingSpaces); } } return listTexts; } // @ts-ignore public PushToOutputStreamIndividual(obj: InkObject | null){ let glue = asOrNull(obj, Glue); let text = asOrNull(obj, StringValue); let includeInOutput = true; if (glue) { this.TrimNewlinesFromOutputStream(); includeInOutput = true; } else if( text ) { let functionTrimIndex = -1; let currEl = this.callStack.currentElement; if (currEl.type == PushPopType.Function) { functionTrimIndex = currEl.functionStartInOutputStream; } let glueTrimIndex = -1; for (let i = this._outputStream.length - 1; i >= 0; i--) { let o = this._outputStream[i]; let c = (o instanceof ControlCommand) ? o : null; let g = (o instanceof Glue) ? o : null; if (g != null) { glueTrimIndex = i; break; } else if (c != null && c.commandType == ControlCommand.CommandType.BeginString) { if (i >= functionTrimIndex) { functionTrimIndex = -1; } break; } } let trimIndex = -1; if (glueTrimIndex != -1 && functionTrimIndex != -1) trimIndex = Math.min(functionTrimIndex, glueTrimIndex); else if (glueTrimIndex != -1) trimIndex = glueTrimIndex; else trimIndex = functionTrimIndex; if (trimIndex != -1) { if (text.isNewline) { includeInOutput = false; } else if (text.isNonWhitespace) { if (glueTrimIndex > -1) this.RemoveExistingGlue(); if (functionTrimIndex > -1) { let callStackElements = this.callStack.elements; for (let i = callStackElements.length - 1; i >= 0; i--) { let el = callStackElements[i]; if (el.type == PushPopType.Function) { el.functionStartInOutputStream = -1; } else { break; } } } } } else if (text.isNewline) { if (this.outputStreamEndsInNewline || !this.outputStreamContainsContent) includeInOutput = false; } } if (includeInOutput) { if (obj === null) { return throwNullException('obj'); } this._outputStream.push(obj); this.OutputStreamDirty(); } } public TrimNewlinesFromOutputStream(){ let removeWhitespaceFrom = -1; let i = this._outputStream.length-1; while (i >= 0) { let obj = this._outputStream[i]; let cmd = asOrNull(obj, ControlCommand); let txt = asOrNull(obj, StringValue); if (cmd != null || (txt != null && txt.isNonWhitespace)) { break; } else if (txt != null && txt.isNewline) { removeWhitespaceFrom = i; } i--; } // Remove the whitespace if (removeWhitespaceFrom >= 0) { i=removeWhitespaceFrom; while(i < this._outputStream.length) { let text = asOrNull(this._outputStream[i], StringValue); if (text) { this._outputStream.splice(i, 1); } else { i++; } } } this.OutputStreamDirty(); } public RemoveExistingGlue(){ for (let i = this._outputStream.length - 1; i >= 0; i--) { let c = this._outputStream[i]; if (c instanceof Glue) { this._outputStream.splice(i, 1); } else if( c instanceof ControlCommand ) { break; } } this.OutputStreamDirty(); } get outputStreamEndsInNewline(){ if (this._outputStream.length > 0) { for (let i = this._outputStream.length - 1; i >= 0; i--) { let obj = this._outputStream[i]; if (obj instanceof ControlCommand) break; let text = this._outputStream[i]; if (text instanceof StringValue) { if (text.isNewline) return true; else if (text.isNonWhitespace) break; } } } return false; } get outputStreamContainsContent(){ for (let i = 0; i < this._outputStream.length; i++){ if (this._outputStream[i] instanceof StringValue) return true; } return false; } get inStringEvaluation(){ for (let i = this._outputStream.length - 1; i >= 0; i--) { // var cmd = this._outputStream[i] as ControlCommand; let cmd = asOrNull(this._outputStream[i], ControlCommand); if (cmd instanceof ControlCommand && cmd.commandType == ControlCommand.CommandType.BeginString) { return true; } } return false; } // @ts-ignore public PushEvaluationStack(obj: InkObject | null){ // var listValue = obj as ListValue; let listValue = asOrNull(obj, ListValue); if (listValue) { // Update origin when list is has something to indicate the list origin let rawList = listValue.value; if (rawList === null) { return throwNullException('rawList'); } if (rawList.originNames != null) { if (!rawList.origins) rawList.origins = []; rawList.origins.length = 0; for (let n of rawList.originNames) { if (this.story.listDefinitions === null) return throwNullException('StoryState.story.listDefinitions'); let def = this.story.listDefinitions.TryListGetDefinition(n, null); if (def.result === null) return throwNullException('StoryState def.result'); if (rawList.origins.indexOf(def.result) < 0) rawList.origins.push(def.result); } } } if (obj === null) { return throwNullException('obj'); } this.evaluationStack.push(obj); } public PopEvaluationStack(): InkObject; public PopEvaluationStack(numberOfObjects: number): InkObject[]; public PopEvaluationStack(numberOfObjects?: number){ if (typeof numberOfObjects === 'undefined'){ let obj = this.evaluationStack.pop(); return nullIfUndefined(obj); } else { if(numberOfObjects > this.evaluationStack.length) { throw new Error('trying to pop too many objects'); } let popped = this.evaluationStack.splice(this.evaluationStack.length - numberOfObjects, numberOfObjects); return nullIfUndefined(popped); } } public PeekEvaluationStack(){ return this.evaluationStack[this.evaluationStack.length - 1]; } public ForceEnd(){ this.callStack.Reset(); this._currentChoices.length = 0; this.currentPointer = Pointer.Null; this.previousPointer = Pointer.Null; this.didSafeExit = true; } public TrimWhitespaceFromFunctionEnd(){ Debug.Assert (this.callStack.currentElement.type == PushPopType.Function); let functionStartPoint = this.callStack.currentElement.functionStartInOutputStream; if (functionStartPoint == -1) { functionStartPoint = 0; } for (let i = this._outputStream.length - 1; i >= functionStartPoint; i--) { let obj = this._outputStream[i]; let txt = asOrNull(obj, StringValue); let cmd = asOrNull(obj, ControlCommand); if (txt == null) continue; if (cmd) break; if (txt.isNewline || txt.isInlineWhitespace) { this._outputStream.splice(i, 1); this.OutputStreamDirty(); } else { break; } } } public PopCallStack(popType: PushPopType | null = null) { if (this.callStack.currentElement.type == PushPopType.Function) this.TrimWhitespaceFromFunctionEnd(); this.callStack.Pop(popType); } public SetChosenPath(path: Path, incrementingTurnIndex: boolean){ // Changing direction, assume we need to clear current set of choices this._currentChoices.length = 0; let newPointer = this.story.PointerAtPath(path); if (!newPointer.isNull && newPointer.index == -1) newPointer.index = 0; this.currentPointer = newPointer; if (incrementingTurnIndex) this._currentTurnIndex++; } public StartFunctionEvaluationFromGame(funcContainer: Container, args: any[]){ this.callStack.Push(PushPopType.FunctionEvaluationFromGame, this.evaluationStack.length); this.callStack.currentElement.currentPointer = Pointer.StartOf(funcContainer); this.PassArgumentsToEvaluationStack(args); } public PassArgumentsToEvaluationStack(args: any[]){ // Pass arguments onto the evaluation stack if (args != null) { for (let i = 0; i < args.length; i++) { if (!(typeof args[i] === 'number' || typeof args[i] === 'string')) { throw new Error('ink arguments when calling EvaluateFunction / ChoosePathStringWithParameters must be int, float or string'); } this.PushEvaluationStack(Value.Create(args[i])); } } } public TryExitFunctionEvaluationFromGame(){ if (this.callStack.currentElement.type == PushPopType.FunctionEvaluationFromGame) { this.currentPointer = Pointer.Null; this.didSafeExit = true; return true; } return false; } public CompleteFunctionEvaluationFromGame(){ if (this.callStack.currentElement.type != PushPopType.FunctionEvaluationFromGame) { throw new StoryException('Expected external function evaluation to be complete. Stack trace: '+this.callStack.callStackTrace); } let originalEvaluationStackHeight = this.callStack.currentElement.evaluationStackHeightWhenPushed; let returnedObj: InkObject | null = null; while (this.evaluationStack.length > originalEvaluationStackHeight) { let poppedObj = this.PopEvaluationStack(); if (returnedObj === null) returnedObj = poppedObj; } this.PopCallStack(PushPopType.FunctionEvaluationFromGame); if (returnedObj) { if (returnedObj instanceof Void) return null; // Some kind of value, if not void // var returnVal = returnedObj as Runtime.Value; let returnVal = asOrThrows(returnedObj, Value); // DivertTargets get returned as the string of components // (rather than a Path, which isn't public) if (returnVal.valueType == ValueType.DivertTarget) { return returnVal.valueObject.toString(); } // Other types can just have their exact object type: // int, float, string. VariablePointers get returned as strings. return returnVal.valueObject; } return null; } public AddError(message: string, isWarning: boolean){ if (!isWarning) { if (this._currentErrors == null) this._currentErrors = []; this._currentErrors.push(message); } else { if (this._currentWarnings == null) this._currentWarnings = []; this._currentWarnings.push(message); } } public OutputStreamDirty(){ this._outputStreamTextDirty = true; this._outputStreamTagsDirty = true; } private _outputStream: InkObject[]; private _outputStreamTextDirty = true; private _outputStreamTagsDirty = true; private _currentChoices: Choice[]; }