import { Container, } from './inkjs/src/Container'; import { ControlCommand, } from './inkjs/src/ControlCommand'; import { Divert, } from './inkjs/src/Divert'; import { NativeFunctionCall, } from './inkjs/src/NativeFunctionCall'; import { throwNullException, } from './inkjs/src/NullException'; import { InkList, InkListItem, } from './inkjs/src/InkList'; import { InkObject, } from './inkjs/src/Object'; import { Pointer, } from './inkjs/src/Pointer'; import { PRNG, } from './inkjs/src/PRNG'; import { PushPopType, } from './inkjs/src/PushPop'; import { Story, } from './inkjs/src/Story'; import { StoryException, } from './inkjs/src/StoryException'; import { StringBuilder, } from './inkjs/src/StringBuilder'; import { assertValid, } from 'ts-assertions'; import { asOrNull, asOrThrows, } from './inkjs/src/TypeAssertion'; import { DivertTargetValue, IntValue, ListValue, StringValue, Value, } from './inkjs/src/Value'; import { VariableAssignment, } from './inkjs/src/VariableAssignment'; import { VariableReference, } from './inkjs/src/VariableReference'; import { Void, } from './inkjs/src/Void'; export class StoryWithDoneEvent extends Story { public readonly PerformLogicAndFlowControl = (contentObj: InkObject | null) => { if (contentObj === null) { return false; } if (contentObj instanceof Divert) { return this.__handleDivert(contentObj); } else if (contentObj instanceof ControlCommand) { return this.__handleControlCommand(contentObj); } else if (contentObj instanceof VariableAssignment) { return this.__handleVariableAssignment(contentObj); } else if (contentObj instanceof VariableReference) { return this.__handleVariableReference(contentObj); } else if (contentObj instanceof NativeFunctionCall) { return this.__handleNativeFunctionCall(contentObj); } /* No control content, must be ordinary content. */ return false; }; public readonly __handleDivert = (currentDivert: Divert) => { if (currentDivert.isConditional) { const conditionValue = this.state.PopEvaluationStack(); // False conditional? Cancel divert if (!this.IsTruthy(conditionValue)) { return true; } } if (currentDivert.hasVariableTarget) { const varName = currentDivert.variableDivertName; const varContents = this.state.variablesState.GetVariableWithName(varName); if (varContents == null) { this.Error( `Tried to divert using a target from a variable that could not be ` + `found (${varName})`, ); } else if (!(varContents instanceof DivertTargetValue)) { // var intContent = varContents as IntValue; const intContent = asOrNull(varContents, IntValue); let errorMessage = `Tried to divert to a target from a variable, ` + `but the variable (${varName}) didn't contain a divert target, it `; if (intContent instanceof IntValue && intContent.value == 0) { errorMessage += 'was empty/null (the value 0).'; } else { errorMessage += `contained "${varContents}".`; } this.Error(errorMessage); } let target = asOrThrows(varContents, DivertTargetValue); this.state.divertedPointer = this.PointerAtPath(target.targetPath); } else if (currentDivert.isExternal) { this.CallExternalFunction(currentDivert.targetPathString, currentDivert.externalArgs); return true; } else { this.state.divertedPointer = currentDivert.targetPointer.copy(); } if (currentDivert.pushesToStack) { this.state.callStack.Push( currentDivert.stackPushType, undefined, this.state.outputStream.length, ); } if (this.state.divertedPointer.isNull && !currentDivert.isExternal) { if (currentDivert && currentDivert.debugMetadata && currentDivert.debugMetadata.sourceName != null) { this.Error( `Divert target doesn't exist: ` + `${currentDivert.debugMetadata.sourceName}.`, ); } else { this.Error(`Divert resolution failed: ${currentDivert}.`); } } return true; }; public readonly __handleControlCommand = (evalCommand: ControlCommand) => { // Start/end an expression evaluation? Or print out the result? const commandType = evalCommand.commandType; if (commandType === ControlCommand.CommandType.EvalStart) { this.Assert(this.state.inExpressionEvaluation === false, 'Already in expression evaluation?'); this.state.inExpressionEvaluation = true; } else if (commandType === ControlCommand.CommandType.EvalEnd) { this.Assert(this.state.inExpressionEvaluation === true, 'Not in expression evaluation mode'); this.state.inExpressionEvaluation = false; } else if (commandType === ControlCommand.CommandType.EvalOutput && this.state.evaluationStack.length) { // If the expression turned out to be empty, there may not be anything on the stack const output = this.state.PopEvaluationStack(); // Functions may evaluate to Void, in which if (we skip output if (!(output instanceof 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. const text = new StringValue(output.toString()); this.state.PushToOutputStream(text); } } else if (commandType === ControlCommand.CommandType.NoOp) { return true; } else if (commandType === ControlCommand.CommandType.Duplicate) { this.state.PushEvaluationStack(this.state.PeekEvaluationStack()); } else if (commandType === ControlCommand.CommandType.PopEvaluatedValue) { this.state.PopEvaluationStack(); } else if (commandType === ControlCommand.CommandType.PopFunction || commandType === ControlCommand.CommandType.PopTunnel) { const popFuncType = ControlCommand.CommandType.PopFunction; const popType = evalCommand.commandType === popFuncType ? PushPopType.Function : PushPopType.Tunnel; let overrideTunnelReturnTarget: DivertTargetValue | null = null; if (popType == PushPopType.Tunnel) { const popped = this.state.PopEvaluationStack(); // overrideTunnelReturnTarget = popped as DivertTargetValue; overrideTunnelReturnTarget = asOrNull(popped, DivertTargetValue); if (overrideTunnelReturnTarget === null) { this.Assert( popped instanceof Void, `Expected void if ->-> doesn't override target.`, ); } } if (this.state.TryExitFunctionEvaluationFromGame()) { return true; } else if (this.state.callStack.currentElement.type !== popType || !this.state.callStack.canPop) { const names: Map = new Map(); names.set(PushPopType.Function, 'function return statement (~ return)'); names.set(PushPopType.Tunnel, 'tunnel onwards statement (->->)'); const expected = this.state.callStack.canPop ? names.get(this.state.callStack.currentElement.type) : 'end of flow (-> END or choice)'; const errorMsg = `Found ${names.get(popType)} when expecting ` + `${expected}.`; this.Error(errorMsg); } else { this.state.PopCallStack(); if (overrideTunnelReturnTarget) { this.state.divertedPointer = this.PointerAtPath(overrideTunnelReturnTarget.targetPath); } } } else if (commandType === ControlCommand.CommandType.BeginString) { this.state.PushToOutputStream(evalCommand); this.Assert( this.state.inExpressionEvaluation === true, 'Expected to be in an expression when evaluating a string.', ); this.state.inExpressionEvaluation = false; } else if (commandType === ControlCommand.CommandType.EndString) { let contentStackForString: InkObject[] = []; let outputCountConsumed = 0; for (let ii = this.state.outputStream.length - 1; ii >= 0; ii -= 1) { const obj = this.state.outputStream[ii]; outputCountConsumed += 1; /* var command = obj as ControlCommand; */ const command = asOrNull(obj, ControlCommand); if (command && command.commandType === ControlCommand.CommandType.BeginString) { break; } if (obj instanceof StringValue) { contentStackForString.push(obj); } } /* Consume the content that was produced for this string. */ this.state.PopFromOutputStream(outputCountConsumed); /* The C# version uses a Stack for contentStackForString, but we're * using a simple array, so we need to reverse it before using it. */ contentStackForString = contentStackForString.reverse(); // Build string out of the content we collected const sb = new StringBuilder(); for (const c of contentStackForString) { sb.Append(c.toString()); } // Return to expression evaluation (from content mode) this.state.inExpressionEvaluation = true; this.state.PushEvaluationStack(new StringValue(sb.toString())); } else if (commandType === ControlCommand.CommandType.ChoiceCount) { const choiceCount = this.state.generatedChoices.length; this.state.PushEvaluationStack(new IntValue(choiceCount)); } else if (commandType === ControlCommand.CommandType.Turns) { const intVal = new IntValue(this.state.currentTurnIndex + 1); this.state.PushEvaluationStack(intVal); } else if (commandType === ControlCommand.CommandType.TurnsSince || commandType === ControlCommand.CommandType.ReadCount) { const target = this.state.PopEvaluationStack(); if (!(target instanceof DivertTargetValue)) { const extraNote = target instanceof IntValue ? '' : `. Did you accidentally pass a read count ('knot_name') instead ` + `of a target ('-> knot_name')?`; this.Error( 'TURNS_SINCE / READ_COUNT expected a divert target (knot, ' + `stitch, label name), but saw ${target}${extraNote}`, ); } /* var divertTarget = target as DivertTargetValue; */ const divertTarget = asOrThrows(target, DivertTargetValue); /* var container = ContentAtPath(divertTarget.targetPath).correctObj as Container; */ const { correctObj } = this.ContentAtPath(divertTarget.targetPath); const container = asOrNull(correctObj, Container); let eitherCount; if (container !== null) { eitherCount = commandType === ControlCommand.CommandType.TurnsSince ? this.TurnsSinceForContainer(container) : this.VisitCountForContainer(container); } else { eitherCount = commandType === ControlCommand.CommandType.TurnsSince ? -1 : 0; this.Warning( `Failed to find container for ${evalCommand.toString()} lookup ` + `at ${divertTarget.targetPath.toString()}.`, ); } this.state.PushEvaluationStack(new IntValue(eitherCount)); } else if (commandType === ControlCommand.CommandType.Random) { const maxInt = asOrNull(this.state.PopEvaluationStack(), IntValue); const minInt = asOrNull(this.state.PopEvaluationStack(), IntValue); if (minInt === null || !(minInt instanceof IntValue)) { return this.Error('Invalid value for minimum parameter of ' + 'RANDOM(min, max).'); } if (maxInt === null || !(maxInt instanceof IntValue)) { return this.Error('Invalid value for maximum parameter of RANDOM(min, max).'); } else if (maxInt.value === null) { /* Originally a primitive type, but here, can be null. * TODO: Replace by default value? */ return throwNullException('maxInt.value'); } else if (minInt.value === null) { return throwNullException('minInt.value'); } const randomRange = maxInt.value - minInt.value + 1; if (randomRange <= 0) { this.Error(`RANDOM was called with minimum as ${minInt.value} and ` + `maximum as ${maxInt.value}. The maximum must be larger.`, ); } const resultSeed = this.state.storySeed + this.state.previousRandom; const random = new PRNG(resultSeed); const nextRandom = random.next(); const chosenValue = (nextRandom % randomRange) + minInt.value; this.state.PushEvaluationStack(new IntValue(chosenValue)); /* Next random number (rather than keeping the Random object * around). */ this.state.previousRandom = nextRandom; } else if (commandType === ControlCommand.CommandType.SeedRandom) { let seed = asOrNull(this.state.PopEvaluationStack(), IntValue); if (seed === null || !(seed instanceof IntValue)) { return this.Error('Invalid value passed to SEED_RANDOM.'); } /* Originally a primitive type, but here, can be null. * TODO: Replace by default value? */ if (seed.value === null) { return throwNullException('minInt.value'); } this.state.storySeed = seed.value; this.state.previousRandom = 0; this.state.PushEvaluationStack(new Void()); } else if (commandType === ControlCommand.CommandType.VisitIndex) { const ptrContainer = this.state.currentPointer.container; const count = this.VisitCountForContainer(ptrContainer) - 1; this.state.PushEvaluationStack(new IntValue(count)); } else if (commandType === ControlCommand.CommandType.SequenceShuffleIndex) { const shuffleIndex = this.NextSequenceShuffleIndex(); this.state.PushEvaluationStack(new IntValue(shuffleIndex)); } else if (commandType === ControlCommand.CommandType.StartThread) { /* Handled in main step function. */ return true; } else if (commandType === 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 (this.state.callStack.canPopThread) { this.state.callStack.PopThread(); } else { /* In normal flow - allow safe exit without warning. */ this.state.didSafeExit = true; /* Stop flow in current thread. */ this.state.currentPointer = Pointer.Null; /* Fire all registered callbacks. */ this.__performDoneCallbacks(); } } else if (commandType === ControlCommand.CommandType.End) { /* Force flow to end completely. */ this.state.ForceEnd(); /* Fire all registered callbacks. */ this.__performDoneCallbacks(); } else if (commandType === ControlCommand.CommandType.ListFromInt) { // var intVal = state.PopEvaluationStack () as IntValue; const intVal = asOrNull(this.state.PopEvaluationStack(), IntValue); // var listNameVal = state.PopEvaluationStack () as StringValue; let listNameVal = asOrThrows(this.state.PopEvaluationStack(), StringValue); if (intVal === null) { throw new StoryException('Passed non-integer when creating a list element from a numerical value.'); } let generatedListValue = null; if (this.listDefinitions === null) { return throwNullException('this.listDefinitions'); } const { exists, result, } = this.listDefinitions.TryListGetDefinition(listNameVal.value, null); if (exists) { // Originally a primitive type, but here, can be null. // TODO: Replace by default value? if (intVal.value === null) { return throwNullException('minInt.value'); } const { exists, result: foundResult, } = result!.TryGetItemWithValue( intVal.value, InkListItem.Null, ); if (exists) { generatedListValue = new ListValue(foundResult, intVal.value); } } else { throw new StoryException('Failed to find LIST called ' + listNameVal.value); } if (generatedListValue === null) { generatedListValue = new ListValue(); } this.state.PushEvaluationStack(generatedListValue); } else if (commandType === ControlCommand.CommandType.ListRange) { let max = asOrNull(this.state.PopEvaluationStack(), Value); let min = asOrNull(this.state.PopEvaluationStack(), Value); /* var targetList = state.PopEvaluationStack () as ListValue; */ const targetList = asOrNull( this.state.PopEvaluationStack(), ListValue, ); if (targetList === null || min === null || max === null) { throw new StoryException( 'Expected list, minimum and maximum for LIST_RANGE', ); } if (targetList.value === null) { return throwNullException('targetList.value'); } const result = targetList.value.ListWithSubRange( min.valueObject, max.valueObject, ); this.state.PushEvaluationStack(new ListValue(result)); } else if (commandType === ControlCommand.CommandType.ListRandom) { let listVal = this.state.PopEvaluationStack() as ListValue; if (listVal === null) { throw new StoryException('Expected list for LIST_RANDOM'); } const { value: list } = listVal; let newList: InkList | null = null; if (list === null) { throw throwNullException('list'); } else if (!list.Count) { newList = new InkList(); } else { /* Generate a random index for the element to take. */ const resultSeed = this.state.storySeed + this.state.previousRandom; const random = new PRNG(resultSeed); const nextRandom = random.next(); const listItemIndex = nextRandom % list.Count; /* This bit is a little different from the original * C# code, since iterators do not work in the same way. * First, we iterate listItemIndex - 1 times, calling next(). * The listItemIndex-th time is made outside of the loop, * in order to retrieve the value. */ const listEnumerator = list.entries(); for (let ii = 0; ii <= listItemIndex - 1; ii += 1) { listEnumerator.next(); } const [ serializedKey, value, ] = listEnumerator.next().value; const key = InkListItem.fromSerializedKey(serializedKey); /* Origin list is simply the origin of the one element */ if (key.originName === null) { return throwNullException('randomItem.Key.originName'); } newList = new InkList(key.originName, this); newList.Add(key, value); this.state.previousRandom = nextRandom; } this.state.PushEvaluationStack(new ListValue(newList)); } else { this.Error(`Unhandled ControlCommand: ${evalCommand}`); } return true; }; public readonly __handleVariableAssignment = ( varAssignment: VariableAssignment, ) => { this.state.variablesState.Assign( varAssignment, this.state.PopEvaluationStack(), ); return true; }; public readonly __handleVariableReference = (varRef: VariableReference) => { let foundValue = null; if (varRef.pathForCount !== null) { // Explicit read count value let container = varRef.containerForCount; let count = this.VisitCountForContainer(container); foundValue = new IntValue(count); } else { // Normal variable reference foundValue = this.state.variablesState.GetVariableWithName(varRef.name); if (foundValue == null) { let defaultVal = this.state.variablesState.TryGetDefaultVariableValue (varRef.name); if (defaultVal != null) { this.Warning( `Variable not found in save state: "${varRef.name}", but seems ` + `to have been newly created. Assigning value from latest ` + `ink's declaration: ${defaultVal}.`, ); foundValue = defaultVal; /* Save for future usage, preventing future errors. Only do this for * variables that are known to be globals, not those that may be * missing temps. */ this.state.variablesState.SetGlobal(varRef.name, foundValue); } else { this.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.`, ); foundValue = new IntValue(0); } } } this.state.PushEvaluationStack(foundValue); return true; }; public readonly __handleNativeFunctionCall = ({ Call, numberOfParameters, }: NativeFunctionCall) => { const funcParams = this.state.PopEvaluationStack(numberOfParameters); const result = Call(funcParams); this.state.PushEvaluationStack(result); return true; }; private readonly __registeredDoneCallbacks: Array< (story: this) => void > = []; public readonly __registerDoneCallback = ( callback: (story: this, ...args: any[]) => void, ) => { this.__registeredDoneCallbacks.push( assertValid( callback, 'The value passed to StoryWithDoneEvent was not a function.', (func) => typeof func === 'function', ) ); }; public readonly __performDoneCallbacks = () => ( this.__registeredDoneCallbacks.forEach((callback) => callback(this)) ); }