code:
import { DebugLogger } from '../DebugLogger';
import { h } from '../hs';

export function id<T extends HTMLElement = HTMLDivElement>(id: string) {
  return document.getElementById(id) as T;
}

export function getTextNodes(parent: HTMLElement, initArray?: Array<Text>) {
  const textNodes: Array<Text> = initArray || [];
  let pointer: Node | null = parent.firstChild;
  while (pointer !== null) {
    if (pointer instanceof HTMLElement) {
      getTextNodes(pointer, textNodes);
    }
    if (pointer instanceof Text) {
      textNodes.push(pointer);
    }
    pointer = pointer.nextSibling;
  }
  return textNodes;
}

const selectNodeDebugLogger = new DebugLogger('Select Node');
export function selectNode(node: Node) {
  try {
    const selection = window.getSelection()!;
    const range = document.createRange();
    range.selectNodeContents(node);
    selection.removeAllRanges();
    selection.addRange(range);
  } catch (error) {
    selectNodeDebugLogger.log('Failed to select node: ', node, '; Error: ', error);
  }
}

export function isAnyParent(
  $element: HTMLElement | null,
  predicate: (node: HTMLElement) => boolean,
) {
  while ($element !== null) {
    if (predicate($element)) {
      return true;
    }
    $element = $element.parentElement;
  }
  return false;
}

export function insertAfter(
  $newElement: HTMLElement,
  $referencingElement: HTMLElement,
) {
  $referencingElement.parentElement!.insertBefore(
    $newElement,
    $referencingElement.nextSibling,
  );
}

export function insertAfterH1(
  $newElement: HTMLElement,
  $parent: HTMLElement,
) {
  const $first = $parent.firstChild;
  if (
    $first !== null &&
    $first instanceof HTMLHeadingElement &&
    $first.tagName.toLowerCase() === 'h1'
  ) {
    insertAfter($newElement, $first);
  } else {
    $parent.prepend($newElement);
  }
}

export function externalLink(text: string, href: string) {
  return h('a.regular', {
    target: '_blank',
    href,
    rel: 'noopener noreferrer',
  }, text);
}

export function linkButton(text: string, callback: () => void) {
  return h('a.regular', {
    href: '#',
    onclick: ((event: any) => {
      event.preventDefault();
      callback();
    }),
  }, text);
}

export function forceReflow($element: HTMLElement) {
  // tslint:disable-next-line:no-unused-expression
  $element.offsetHeight;
}

export function autoExpandTextArea($textarea: HTMLTextAreaElement, minHeightPx: number = 120) {
  function update() {
    $textarea.style.height = `1px`;
    $textarea.style.height = `${Math.max(minHeightPx, $textarea.scrollHeight)}px`;
  }
  $textarea.addEventListener('input', update, false);
  setTimeout(update, 1);
  return update;
}


code:
export function lastElement<T>(array: Array<T>): T {
  return array[array.length - 1];
}

export function range(fromInclusive: number, toExclusive: number, step: number = 1) {
  const result: Array<number> = [];
  for (let i = fromInclusive; i < toExclusive; i += step) {
    result.push(i);
  }
  return result;
}

export function produce<T>(count: number, producer: () => T) {
  const result: Array<T> = [];
  for (let i = 0; i < count; i++) {
    result.push(producer());
  }
  return result;
}

license: unknown


code:
export function commaNumber(num: number) {
  const segments: Array<string> = [];
  while (num >= 1000) {
    segments.push(String(num % 1000).padStart(3, '0'));
    num = Math.floor(num / 1000);
  }
  segments.push(String(num));
  segments.reverse();
  return segments.join(',');
}


code:
export function formatRelativePath(relativePath: string) {
  if (relativePath.endsWith('.html')) {
    relativePath = relativePath.substr(0, relativePath.length - '.html'.length);
  }
  relativePath = relativePath.replace(/\//g, ' > ');

  relativePath = relativePath.replace(/-/g, ' ');
  return relativePath;
}


code:
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
const MONTH = 365 / 12 * DAY; // Doesn't matter
const YEAR = 365 * DAY;

const MAX_RELATIVE_TIME = 7 * DAY;

export function formatTimeRelative(time: Date) {
  const relativeTime = Date.now() - time.getTime();
  if (relativeTime > MAX_RELATIVE_TIME) {
    return `${time.getFullYear()}/${time.getMonth() + 1}/${time.getDate()}`;
  }
  if (relativeTime > DAY) {
    return `${Math.floor(relativeTime / DAY)} 天前`;
  }
  if (relativeTime > HOUR) {
    return `${Math.floor(relativeTime / HOUR)} 小时前`;
  }
  if (relativeTime > MINUTE) {
    return `${Math.floor(relativeTime / MINUTE)} 分钟前`;
  }
  return `${Math.floor(relativeTime / SECOND)} 秒前`;
}

export function formatTimeRelativeLong(time: Date) {
  const relativeTime = Date.now() - time.getTime();
  if (relativeTime <= MAX_RELATIVE_TIME) {
    return formatTimeRelative(time);
  }
  if (relativeTime > YEAR) {
    return `${Math.floor(relativeTime / YEAR)} 年前`;
  }
  if (relativeTime > MONTH) {
    return `${Math.floor(relativeTime / MONTH)} 个月前`;
  }
  return `${Math.floor(relativeTime / DAY)} 天前`;
}

export function formatDurationCoarse(milliseconds: number) {
  if (milliseconds > 10 * DAY) {
    return ` ${Math.floor(milliseconds / DAY)} 天`;
  } else if (milliseconds > HOUR) {
    const day = Math.floor(milliseconds / DAY);
    const hour = Math.floor((milliseconds % DAY) / HOUR);
    if (hour === 0) {
      return ` ${day} 天`;
    } else {
      return ` ${day} 天 ${hour} 小时`;
    }
  } else {
    return '不到一小时';
  }
}

export function formatTimeSimple(time: Date) {
  return `${time.getFullYear()}-${time.getMonth() + 1}-${time.getDate()} ` +
    `${time.getHours()}:${time.getMinutes()}:${time.getSeconds()}`;
}


code:
import { DebugLogger } from '../DebugLogger';
import { matchAll } from './matchAll';

const debugLogger = new DebugLogger('Load Google Fonts');
const parseRegex = /@font-face {[^}]*?font-family:\s*['"]?([^;'"]+?)['"]?;[^}]*?font-style:\s*([^;]+);[^}]*?font-weight:\s*([^;]+);[^}]*?src:\s*([^;]+);[^}]*?(?:unicode-range:\s*([^;]+))?;/g;
export async function loadGoogleFonts(fontName: string) {
  const cssLink = `https://fonts.googleapis.com/css2?family=${fontName.replace(/ /g, '+')}`;
  debugLogger.log(`Loading font: "${fontName}" from "${cssLink}".`);
  const response = await fetch(cssLink);
  const text = await response.text();
  const matches = matchAll(text, parseRegex);
  return Promise.all(matches.map(match => new FontFace(match[1], match[4], {
    style: match[2],
    weight: match[3],
    unicodeRange: match[5],
  }).load()))
    .then(fontFaces => fontFaces.map(fontFace => (document.fonts as any).add(fontFace)))
    .then(() => fontName);
}


code:
export function matchAll(str: string, regex: RegExp): Array<RegExpExecArray> {
  if (regex.global !== true) {
    throw new Error('Global flag is required.');
  }
  const results = [];
  let array;
  while ((array = regex.exec(str)) !== null) {
    results.push(array);
  }
  return results;
}


code:
export function randomInt(minInclusive: number, maxExclusive: number) {
  return Math.floor(Math.random() * (maxExclusive - minInclusive)) + minInclusive;
}

export function randomNumber(minInclusive: number, maxExclusive: number) {
  return Math.random() * (maxExclusive - minInclusive) + minInclusive;
}


code:
const cjkBeginRegex = /^(?:[\u4E00-\u9FCC\u3400-\u4DB5\uFA0E\uFA0F\uFA11\uFA13\uFA14\uFA1F\uFA21\uFA23\uFA24\uFA27-\uFA29]|[\ud840-\ud868][\udc00-\udfff]|\ud869[\udc00-\uded6\udf00-\udfff]|[\ud86a-\ud86c][\udc00-\udfff]|\ud86d[\udc00-\udf34\udf40-\udfff]|\ud86e[\udc00-\udc1d])/;
const cjkEndRegex = /(?:[\u4E00-\u9FCC\u3400-\u4DB5\uFA0E\uFA0F\uFA11\uFA13\uFA14\uFA1F\uFA21\uFA23\uFA24\uFA27-\uFA29]|[\ud840-\ud868][\udc00-\udfff]|\ud869[\udc00-\uded6\udf00-\udfff]|[\ud86a-\ud86c][\udc00-\udfff]|\ud86d[\udc00-\udf34\udf40-\udfff]|\ud86e[\udc00-\udc1d])$/;

export function padName(name: string) {
  if (!cjkBeginRegex.test(name)) {
    name = ' ' + name;
  }
  if (!cjkEndRegex.test(name)) {
    name = name + ' ';
  }
  return name;
}


code:
/**
 * Input: ['a/b', '..', 'c/../e', 'f']
 * Output: 'a/e/f'
 */
export function resolvePath(...paths: Array<string>) {
  const pathStack: Array<string> = [];
  for (const path of paths) {
    const segments = path.split('/');
    for (const segment of segments) {
      switch (segment) {
        case '':
        case '.':
          return null;
        case '..':
          if (pathStack.length === 0) {
            return null;
          } else {
            pathStack.pop();
          }
          break;
        default:
          pathStack.push(segment);
      }
    }
  }
  return pathStack.join('/');
}


code:
export function shortNumber(input: number, digits: number = 1) {
  if (input < 1_000) {
    return String(input);
  }
  if (input < 1_000_000) {
    return (input / 1000).toFixed(digits) + 'k';
  }
  return (input / 1_000_000).toFixed(digits) + 'M';
}


code:
/**
 * Input: 'asdasd.html', '.html'
 * Output: 'asdasd'
 *
 * Input: 'asdasd', '.html'
 * Output: 'asdasd'
 */
export function removePotentialSuffix(input: string, potentialSuffix: string) {
  return input.endsWith(potentialSuffix)
    ? input.substr(0, input.length - potentialSuffix.length)
    : input;
}


code:
// https://stackoverflow.com/a/7616484
export function stringHash(str: string) {
  let hash = 0;
  if (str.length === 0) {
    return hash;
  }
  for (let i = 0; i < str.length; i++) {
    hash  = ((hash << 5) - hash) + str.charCodeAt(i);
    hash |= 0;
  }
  return hash;
}


code:
import { $e } from '../$e';
import { tagCountMap } from '../data/data';

export function tagSpan(tag: string, active: boolean) {
  const count = tagCountMap.get(tag) ?? 0;
  return (

        { tag }
        { count }

  );
}


code:
export function smartThrottle(fn: () => void, timeMs: number) {
  let timerRunning = false;
  let scheduled = false;
  return () => {
    if (!timerRunning) {
      fn();
      const timedOut = () => {
        if (scheduled) {
          fn();
          scheduled = false;
          setTimeout(timedOut, timeMs);
        } else {
          timerRunning = false;
        }
      };
      timerRunning = true;
      setTimeout(timedOut, timeMs);
    } else {
      scheduled = true;
    }
  };
}


code:
/**
 * Completely replaces the pre-existing arguments.
 */
export function resetUrlArgumentsTo(args: Map<string, string>) {
  window.history.replaceState(
    null,
    document.title,
    window.location.origin + '/' + window.location.hash.split('~')[0]
      + ((args.size === 0)
        ? ''
        : ('~' + Array.from(args).map(([key, value]) => (value === '') ? key : `${key}=${value}`))),
  );
}


code:
export class ChainedCanvas {
  public readonly canvas: HTMLCanvasElement;
  public readonly ctx: CanvasRenderingContext2D;
  private promise: Promise<any> = Promise.resolve();
  public constructor(width: number, height: number) {
    this.canvas = document.createElement('canvas');
    this.canvas.width = width;
    this.canvas.height = height;
    this.ctx = this.canvas.getContext('2d')!;
  }
  public updatePromise(updater: () => Promise<any>) {
    this.promise = this.promise.then(updater);
  }
  public onResolve(callback: () => void) {
    this.promise.then(callback);
  }
  public getWidth() {
    return this.canvas.width;
  }
  public getHeight() {
    return this.canvas.height;
  }
}


code:
/**
 * Implement methods to provide additional feature support.
 */
export class FeatureProvider {
  /**
   * When using the canvas functionality of WTCD, user may choose to put an
   * external image to the canvas. Whenever that happens, this method is called.
   */
  loadImage(path: string): Promise<CanvasImageSource> {
    return Promise.reject('Loading images is not allowed.');
  }
  /**
   * When using the canvas functionality of WTCD, user may choose to use a
   * custom font in the canvas. Whenever that happens, this method is called.
   *
   * Please make sure the font is loaded in DOM via document.fonts#add
   *
   * Returns the name of the font
   */
  loadFont(identifier: string): Promise<string> {
    return Promise.reject('Loading fonts is not allowed.');
  }
  /**
   * Draw loading screen on the provided canvas.
   */
  drawLoadingCanvas($canvas: HTMLCanvasElement): void {
    return undefined;
  }
}

export const defaultFeatureProvider = new FeatureProvider();


code:
import { ContentOutput, Interpreter } from './Interpreter';
import { Random } from './Random';
import { WTCDRoot } from './types';
import { FeatureProvider, defaultFeatureProvider } from './FeatureProvider';

/** Data persisted in the localStorage */
interface Data {
  random: string;
  decisions: Array<number>;
}

/**
 * This is one of the possible implementations of a WTCD reader.
 *
 * In this implementation, all new content and buttons are appended to a single
 * HTML element. The user is expected to continuously scroll down the page in
 * order to read more, thus the name "flow reader".
 *
 * This reader implementation persists data via memorizing all users' decisions.
 * When restoring a previous session, it replays all decisions.
 *
 * Since all decisions are recorded, this implementation allows the user to undo
 * decisions, in which case, it resets the interpreter and replay all decisions
 * until the decision that is being undone. This means, however, if the logic of
 * WTCD section is extremely complicated and takes a long time to compute, it
 * will potentially lag user's interface every time the user undoes a decision.
 */
export class FlowReader {
  /** The interpreter */
  private interpreter!: Interpreter;
  /** The iterator of the interpreter */
  private interpreterIterator!: Iterator<ContentOutput, ContentOutput, number>;
  /** Key in local storage */
  private storageKey: string;
  /** Persisted data */
  private data: Data;
  /** Where to render the output */
  private target!: HTMLElement;
  /** Which decision the current buttons are for */
  private currentDecisionIndex: number = 0;
  /** Buttons for each group of output */
  private buttons: Array<Array<HTMLDivElement>> = [];
  /** Content output after each decision */
  private contents: Array<HTMLElement> = [];
  /**
   * Verify and parse data stored in localStorage.
   */
  private parseData(data: any): Data | null {
    if (typeof data !== 'string') {
      return null;
    }
    let obj: any;
    try {
      obj = JSON.parse(data);
    } catch (error) {
      return null;
    }
    if (typeof obj.random !== 'string') {
      return null;
    }
    if (!Array.isArray(obj.decisions)) {
      return null;
    }
    if (obj.decisions.some((decision: any) => typeof decision !== 'number')) {
      return null;
    }
    return obj;
  }
  /** Fancy name for "save" */
  private persist() {
    window.localStorage.setItem(this.storageKey, JSON.stringify(this.data));
  }
  /**
   * Calls this.interpreterIterator.next() and handles error.
   */
  private next(decision?: number): IteratorResult<ContentOutput, ContentOutput> {
    try {
      return this.interpreterIterator.next(decision as any);
    } catch (error) {
      const $errorMessage = this.errorMessageCreator(error as Error);
      this.target.appendChild($errorMessage);
      this.contents.push($errorMessage);
      return {
        done: true,
        value: {
          choices: [],
          content: [],
        },
      };
    }
  }
  /** Restart the interpreter and reset the interpreterIterator */
  public resetInterpreter() {
    this.interpreter = new Interpreter(
      this.wtcdRoot,
      new Random(this.data.random),
      this.featureProvider,
    );
    this.interpreterIterator = this.interpreter.start();
  }
  public constructor(
    docIdentifier: string,
    private wtcdRoot: WTCDRoot,
    private errorMessageCreator: (error: Error) => HTMLElement,
    private elementPreprocessor: ($element: HTMLElement) => void,
    private featureProvider: FeatureProvider = defaultFeatureProvider,
  ) {
    this.storageKey = `wtcd.fr.${docIdentifier}`;
    this.data = this.parseData(window.localStorage.getItem(this.storageKey)) || {
      random: String(Math.random()),
      decisions: [],
    };
    this.resetInterpreter();
  }

  /**
   * Make a decision at currentDecisionIndex and update buttons accordingly
   *
   * @param decision the index of choice to be made
   * @param replay whether this is during a replay; If true, the decision will
   * not be added to data.
   */
  private decide(decision: number, replay: boolean = false) {
    this.buttons[this.currentDecisionIndex].forEach(($button, choiceIndex) => {
      if ($button.classList.contains('disabled')) {
        return;
      }
      $button.classList.remove('candidate');
      if (choiceIndex === decision) {
        $button.classList.add('selected');
      } else {
        $button.classList.add('unselected');
      }
    });
    if (!replay) {
      this.data.decisions.push(decision);
    }
    // Advance current decision index
    this.currentDecisionIndex++;
    const yieldValue = this.next(decision);
    this.handleOutput(yieldValue.value);
    return yieldValue.done;
  }

  /**
   * Undo a decision made previously; It also removes every decision after the
   * specified decision.
   *
   * @param decisionIndex which decision to be undone
   */
  private undecide(decisionIndex: number) {
    this.resetInterpreter();

    // Clear those no longer needed content
    this.data.decisions.splice(decisionIndex);
    this.buttons.splice(decisionIndex + 1);
    this.contents.splice(decisionIndex + 1)
      .forEach($deletedContent => $deletedContent.remove());

    // Replay
    this.next();
    for (const decision of this.data.decisions) {
      this.next(decision);
    }

    // Update current decision's buttons so they become available to click
    // again.
    this.buttons[decisionIndex].forEach($button => {
      if (!$button.classList.contains('disabled')) {
        $button.classList.remove('selected', 'unselected');
        $button.classList.add('candidate');
      }
    });

    this.currentDecisionIndex = decisionIndex;
  }

  /**
   * Handle an instance of output from the interpreter. This will add the
   * content output and buttons to target.
   *
   * @param output the content output to be added
   */
  private handleOutput(output: ContentOutput) {
    // Create a container for all elements involved so deletion will be easier.
    const $container = document.createElement('div');
    $container.classList.add('wtcd-group-container');
    output.content.forEach($element => $container.appendChild($element));
    this.interpreter.getPinned().forEach($element =>
      $container.appendChild($element),
    );
    const decisionIndex = this.currentDecisionIndex;
    this.buttons.push(output.choices.map((choice, choiceIndex) => {
      const $button = document.createElement('div');
      $button.classList.add('wtcd-button');
      $button.innerText = choice.content;
      if (choice.disabled) {
        $button.classList.add('disabled');
      } else {
        $button.classList.add('candidate');
        $button.addEventListener('click', () => {
          if (this.data.decisions[decisionIndex] === choiceIndex) {
            this.undecide(decisionIndex);
            this.persist();
          } else if (this.currentDecisionIndex === decisionIndex) {
            this.decide(choiceIndex);
            this.persist();
          }
        });
      }
      $container.appendChild($button);
      return $button;
    }));

    this.contents.push($container);
    this.target.appendChild($container);
    this.elementPreprocessor($container);
  }

  private started: boolean = false;
  public renderTo($target: HTMLElement) {
    if (this.started) {
      throw new Error('Flow reader already started.');
    }
    this.started = true;
    this.target = $target;

    const init  = this.next();
    let done = init.done;
    this.handleOutput(init.value);
    for (const decision of this.data.decisions) {
      if (done) {
        return;
      }
      done = this.decide(decision, true);
    }
  }
}


code:
import { ContentOutput, Interpreter } from './Interpreter';
import { Random } from './Random';
import { WTCDRoot } from './types';
import { FeatureProvider, defaultFeatureProvider } from './FeatureProvider';

interface GameData {
  random: string;
  decisions: Array<number>;
}
function isGameData(data: any): data is GameData {
  if (typeof data !== 'object' || data === null) {
    return false;
  }
  if (typeof data.random !== 'string') {
    return false;
  }
  if (!Array.isArray(data.decisions)) {
    return false;
  }
  if (data.decisions.some((decision: any) => typeof decision !== 'number')) {
    return false;
  }
  return true;
}
interface SaveData extends GameData {
  date: number;
  desc: string;
}
function isSaveData(data: any): data is SaveData {
  if (!isGameData(data)) {
    return false;
  }
  if (typeof (data as any).date !== 'number') {
    return false;
  }
  if (typeof (data as any).desc !== 'string') {
    return false;
  }
  return true;
}
interface Data {
  saves: Array<SaveData | null>;
  current: GameData;
}
function isData(data: any): data is Data {
  if (typeof data !== 'object' || data === null) {
    return false;
  }
  if (!isGameData(data.current)) {
    return false;
  }
  if (!Array.isArray(data.saves)) {
    return false;
  }
  if (!data.saves.every((save: any) => save === null || isSaveData(save))) {
    return false;
  }
  return true;
}

/**
 * This is one of the possible implementations of a WTCD reader.
 *
 * This is a reader specialized for games. This reader only display one section
 * at a time. This reader also does not allow undo.
 *
 * However, this reader does support save/load. It persists data via memorizing
 * all decisions too.
 */
export class GameReader {
  private storageKey: string;
  private data: Data;
  /** The interpreter */
  private interpreter!: Interpreter;
  /** The iterator of the interpreter */
  private interpreterIterator!: Iterator<ContentOutput, ContentOutput, number>;
  public constructor(
    docIdentifier: string,
    private wtcdRoot: WTCDRoot,
    private onOutput: (content: HTMLDivElement) => void,
    private onError: (error: Error) => void,
    private featureProvider: FeatureProvider = defaultFeatureProvider,
  ) {
    this.storageKey = `wtcd.gr.${docIdentifier}`;
    this.data = this.parseData(
      window.localStorage.getItem(this.storageKey),
    ) || {
      saves: [null, null, null],
      current: {
        random: String(Math.random()),
        decisions: [],
      },
    };
  }
  private parseData(data: any): Data | null {
    if (typeof data !== 'string') {
      return null;
    }
    let obj: any;
    try {
      obj = JSON.parse(data);
    } catch (error) {
      return null;
    }
    if (!isData(obj)) {
      return null;
    }
    return obj;
  }
  private persist() {
    window.localStorage.setItem(this.storageKey, JSON.stringify(this. data));
  }
  public getSaves() {
    return this.data.saves.map(save => save === null
      ? null
      : {
        desc: save.desc,
        date: new Date(save.date),
      },
    );
  }
  public reset(reseed: boolean) {
    this.data.current.decisions = [];
    if (reseed) {
      this.data.current.random = String(Math.random());
    }
    this.restoreGameState();
    this.persist();
  }
  public save(saveIndex: number) {
    const save = this.data.saves[saveIndex];
    if (save === undefined) {
      throw new Error(`Illegal save index: ${saveIndex}`);
    }
    this.data.saves[saveIndex] = {
      date: Date.now(),
      desc: this.interpreter.getStateDesc() || '',
      random: this.data.current.random,
      decisions: this.data.current.decisions.slice(),
    };
    if (this.data.saves[this.data.saves.length - 1] !== null) {
      this.data.saves.push(null);
    }
    this.persist();
  }
  public load(saveIndex: number) {
    const save = this.data.saves[saveIndex];
    if (save === undefined || save === null) {
      throw new Error(`Illegal save index: ${saveIndex}.`);
    }
    this.data.current.random = save.random;
    this.data.current.decisions = save.decisions.slice();
    this.restoreGameState();
    this.persist();
  }
  /** Calls this.interpreterIterator.next() and handles error. */
  private next(
    decision?: number,
  ): IteratorResult<ContentOutput, ContentOutput> {
    try {
      return this.interpreterIterator.next(decision as any);
    } catch (error) {
      this.onError(error as Error);
      return {
        done: true,
        value: {
          choices: [],
          content: [],
        },
      };
    }
  }
  private restoreGameState() {
    this.interpreter = new Interpreter(
      this.wtcdRoot,
      new Random(this.data.current.random),
      this.featureProvider,
    );
    this.interpreterIterator = this.interpreter.start();
    let lastOutput = this.next();
    this.data.current.decisions.forEach(decision =>
      lastOutput = this.next(decision),
    );
    this.handleOutput(lastOutput.value);
  }
  private handleOutput(output: ContentOutput) {
    const $output = document.createElement('div');
    output.content.forEach($element => $output.appendChild($element));
    this.interpreter.getPinned()
      .forEach($element => $output.appendChild($element));
    const decisionIndex = this.data.current.decisions.length;
    const buttons = output.choices.map((choice, choiceIndex) => {
      const $button = document.createElement('div');
      $button.classList.add('wtcd-button');
      $button.innerText = choice.content;
      if (choice.disabled) {
        $button.classList.add('disabled');
      } else {
        $button.classList.add('candidate');
        $button.addEventListener('click', () => {
          if (decisionIndex !== this.data.current.decisions.length) {
            return;
          }
          this.data.current.decisions.push(choiceIndex);
          buttons.forEach($eachButton => {
            if ($eachButton === $button) {
              $eachButton.classList.add('selected');
            } else {
              $eachButton.classList.add('unselected');
            }
          });
          this.handleOutput(this.next(choiceIndex).value);
          this.persist();
        });
      }
      $output.appendChild($button);
      return $button;
    });
    this.onOutput($output);
  }
  private started = false;
  public start() {
    if (this.started) {
      throw new Error('Game reader already started.');
    }
    this.started = true;
    this.restoreGameState();
  }
}


code:
import { getMaybePooled } from './constantsPool';
import { FunctionInvocationError, invokeFunctionRaw } from './invokeFunction';
import { binaryOperators, unaryOperators } from './operators';
import { Random } from './Random';
import { stdFunctions } from './std';
import {
  BlockExpression,
  ChoiceExpression,
  ConditionalExpression,
  DeclarationStatement,
  Expression,
  FunctionExpression,
  IfExpression,
  ListExpression,
  NativeFunction,
  OptionalLocationInfo,
  RegisterName,
  Section,
  SelectionAction,
  Statement,
  SwitchExpression,
  VariableType,
  WhileExpression,
  WTCDRoot,
} from './types';
import { arrayEquals, flat } from './utils';
import { WTCDError } from './WTCDError';
import { FeatureProvider } from './FeatureProvider';
import { ChainedCanvas } from './ChainedCanvas';

// Dispatching code for the runtime of WTCD

interface SingleChoice {
  content: string;
  disabled: boolean;
}

export interface ContentOutput {
  readonly content: Array<HTMLElement>;
  readonly choices: Array<SingleChoice>;
}

export type NumberValueRaw = number;

export interface NumberValue {
  readonly type: 'number';
  readonly value: NumberValueRaw;
}

export type BooleanValueRaw = boolean;

export interface BooleanValue {
  readonly type: 'boolean';
  readonly value: BooleanValueRaw;
}

export type StringValueRaw = string;

export interface StringValue {
  readonly type: 'string';
  readonly value: StringValueRaw;
}

export type NullValueRaw = null;

export interface NullValue {
  readonly type: 'null';
  readonly value: NullValueRaw;
}

export type ListValueRaw = ReadonlyArray<RuntimeValue>;

export interface ListValue {
  readonly type: 'list';
  readonly value: ListValueRaw;
}

export type ActionValueRaw = {
  readonly action: 'goto';
  readonly target: Array<string>;
} | {
  readonly action: 'exit';
} | {
  readonly action: 'selection';
  readonly choices: ReadonlyArray<ChoiceValue>;
} | {
  readonly action: 'function';
  readonly fn: FunctionValue;
  readonly creator: ChoiceExpression;
};

export interface ActionValue {
  readonly type: 'action';
  readonly value: ActionValueRaw;
}

export interface ChoiceValueRaw {
  readonly text: string;
  readonly action: ActionValue | NullValue;
}

export interface ChoiceValue {
  readonly type: 'choice';
  readonly value: ChoiceValueRaw;
}

export type FunctionValueRaw = {
  readonly fnType: 'wtcd',
  readonly expr: FunctionExpression;
  readonly captured: ReadonlyArray<{
    readonly name: string,
    readonly value: Variable,
  }>;
} | {
  readonly fnType: 'native',
  readonly nativeFn: NativeFunction,
} | {
  readonly fnType: 'partial';
  readonly isLeft: boolean;
  readonly applied: ReadonlyArray<RuntimeValue>;
  readonly targetFn: FunctionValue,
};

export interface FunctionValue {
  readonly type: 'function';
  readonly value: FunctionValueRaw;
}

export type RuntimeValue
  = NumberValue
  | BooleanValue
  | StringValue
  | NullValue
  | ActionValue
  | ChoiceValue
  | ListValue
  | FunctionValue;

/**
 * Represents all possible type names for runtime values.
 */
export type RuntimeValueType = RuntimeValue['type'];

/**
 * Represents a variable. It stores its type information and its value.
 */
export interface Variable {
  readonly types: null | Array<RuntimeValueType>;
  value: RuntimeValue;
}

/**
 * Extracts the unwrapped value type for a give runtime value type name
 *
 * For example, RuntimeValueRaw<"number"> = number
 */
export type RuntimeValueRaw<T extends RuntimeValueType> = Extract<
  RuntimeValue,
  { type: T }
>['value'];

/**
 * Determine whether two runtime values are equal. Uses deep value equality
 * instead of reference equality.
 */
export function isEqual(v0: RuntimeValue, v1: RuntimeValue): boolean {
  // TypeScript's generic currently does not support type narrowing.
  // Until that is fixed, this function has to have so many any, unfortunately
  if (v0.type !== v1.type) {
    return false;
  }
  switch (v0.type) {
    case 'null':
      return true;
    case 'number':
    case 'boolean':
    case 'string':
      return v0.value === v1.value;
    case 'action':
      if (v0.value.action !== (v1 as any).value.action) {
        return false;
      }
      switch (v0.value.action) {
        case 'exit':
          return true;
        case 'goto':
          return arrayEquals(v0.value.target, (v1 as any).value.target);
        case 'selection':
          return (v0.value.choices.length
            === (v1 as any).value.choices.length) &&
            (v0.value.choices.every((choice, index) => isEqual(
              choice,
              (v1 as any).value.choices[index]),
            ));
      }
      throw new Error('Shouldn\'t fall through.');
    case 'choice':
      return (
        (v0.value.text === (v1 as any).value.text) &&
        (isEqual(v0.value.action, (v1 as any).value.action))
      );
    case 'function':
      if (v0.value.fnType !== (v1 as FunctionValue).value.fnType) {
        return false;
      }
      if (v0.value.fnType === 'native') {
        return (v0.value.nativeFn === (v1 as any).value.nativeFn);
      } else if (v0.value.fnType === 'wtcd') {
        return (
          // They refer to same expression
          (v0.value.expr === (v1 as any).value.expr) &&
          (v0.value.captured.every((v0Cap, index) => {
            const v1Cap = (v1 as any).value.captured[index];
            return (
              (v0Cap.name === v1Cap.name) &&
              // Reference equality to make sure they captured the same
              // variable
              (v0Cap.value === v1Cap.value)
            );
          }))
        );
      } else {
        return (
          (v0.value.isLeft === (v1 as any).value.isLeft) &&
          (isEqual(v0.value.targetFn, (v1 as any).value.targetFn)) &&
          (arrayEquals(v0.value.applied, (v1 as any).value.applied, isEqual))
        );
      }
    case 'list':
      return (
        (v0.value.length === (v1 as ListValue).value.length) &&
        (v0.value.every((element, index) => isEqual(
          element,
          (v1 as ListValue).value[index]),
        ))
      );
  }
}

/**
 * An evaluator is responsible for evaluating an expression and return its value
 */
export type Evaluator = (expr: Expression) => RuntimeValue;

/**
 * Type of a thrown bubble signal.
 */
export enum BubbleSignalType {
  YIELD,
  RETURN,
  BREAK,
  CONTINUE,
}

/**
 * Bubble signal is used for traversing upward the call stack. It is implemented
 * with JavaScript's Error. Such signal might be yield, return, break, or
 * continue.
 */
export class BubbleSignal extends Error {
  public constructor(
    public readonly type: BubbleSignalType,
  ) {
    super('Uncaught Bubble Signal.');
    Object.setPrototypeOf(this, new.target.prototype);
  }
}

/**
 * Create a string describing a runtime value including its type and value.
 */
export function describe(rv: RuntimeValue): string {
  switch (rv.type) {
    case 'number':
    case 'boolean':
      return `${rv.type} (value = ${rv.value})`;
    case 'string':
      return `string (value = "${rv.value}")`;
    case 'choice':
      return `choice (action = ${describe(rv.value.action)}, label = ` +
        `"${rv.value.text}")`;
    case 'action':
      switch (rv.value.action) {
        case 'goto':
          return `action (type = goto, target = ${rv.value.target})`;
        case 'exit':
          return `action (type = exit)`;
        case 'selection':
          return `action (type = selection, choices = [${rv.value.choices
            .map(describe).join(', ')}])`;
      }
    case 'null':
      return 'null';
    case 'list':
      return `list (elements = [${rv.value.map(describe).join(', ')}])`;
    case 'function':
      if (rv.value.fnType === 'native') {
        return `function (native ${rv.value.nativeFn.name})`;
      } else if (rv.value.fnType === 'wtcd') {
        return `function (arguments = [${rv.value.expr.arguments
          .map(arg => arg.name)
          .join(', ')
        }])`;
      } else {
        return `function (partial ${rv.value.isLeft ? 'left' : 'right'}, ` +
          `applied = [${rv.value.applied.map(describe).join(', ')}], ` +
          `targetFn = ${describe(rv.value.targetFn)})`;
      }
  }
}

/**
 * Determine whether a given value is assignable to a given type declaration.
 *
 * @param type given value's type
 * @param types type declaration that is to be compared to
 * @returns whether a value with type type is assignable to a variable with type
 * declaration types
 */
export function isTypeAssignableTo(
  type: RuntimeValueType,
  types: VariableType,
) {
  return types === null || types.includes(type);
}

export function assignValueToVariable(
  variable: Variable,
  value: RuntimeValue,
  location: OptionalLocationInfo, // For error message
  variableName: string, // For error message
) {
  if (!isTypeAssignableTo(value.type, variable.types)) {
    throw WTCDError.atLocation(location, `Cannot assign value (` +
      `${describe(value)}) to variable "${variableName}". "${variableName}" ` +
      `can only store these types: ${(
        variable.types as Array<RuntimeValueType>
      ).join(', ')}`);
  }
  variable.value = value;
}

class RuntimeScope {
  private variables: Map<string, Variable> = new Map();
  private registers: Map<string, RuntimeValue> | null = null;
  /**
   * Attempt to resolve the given variable name within this scope. If variable
   * is not found, return null.
   *
   * @param variableName
   * @returns
   */
  public resolveVariableReference(variableName: string): Variable | null {
    return this.variables.get(variableName) || null;
  }
  public addVariable(variableName: string, value: Variable) {
    this.variables.set(variableName, value);
  }
  public addRegister(registerName: RegisterName) {
    if (this.registers === null) {
      this.registers = new Map();
    }
    this.registers.set(registerName, getMaybePooled('null', null));
  }
  /**
   * If a register with given name exists on this scope, set the value of it and
   * return true. Otherwise, return false.
   *
   * @param registerName name of register
   * @param value value to set to
   * @returns whether the requested register is found
   */
  public setRegisterIfExist(registerName: RegisterName, value: RuntimeValue): boolean {
    if (this.registers === null) {
      return false;
    }
    if (!this.registers.has(registerName)) {
      return false;
    }
    this.registers.set(registerName, value);
    return true;
  }
  public getRegister(registerName: RegisterName): RuntimeValue | null {
    return this.registers && this.registers.get(registerName) || null;
  }
}

export class InvalidChoiceError extends Error {
  public constructor(
    public readonly choiceId: number,
  ) {
    super(`Invalid choice ${choiceId}.`);
  }
}

export interface InterpreterHandle {
  evaluator(expr: Expression): RuntimeValue;
  pushScope(): RuntimeScope;
  popScope(): RuntimeScope | undefined;
  resolveVariableReference(variableName: string): Variable;
  getRandom(): Random;
  pushContent($element: HTMLElement): void;
  readonly timers: Map<string, number>;
  setPinnedFunction(pinnedFunction: FunctionValue | null): void;
  setStateDesc(stateDesc: string | null): void;
  readonly featureProvider: FeatureProvider;
  readonly canvases: Map<string, ChainedCanvas>;
}

export class Interpreter {
  private timers: Map<string, number> = new Map();
  private interpreterHandle: InterpreterHandle = {
    evaluator: this.evaluator.bind(this),
    pushScope: this.pushScope.bind(this),
    popScope: this.popScope.bind(this),
    resolveVariableReference: this.resolveVariableReference.bind(this),
    getRandom: () => this.random,
    pushContent: this.pushContent.bind(this),
    timers: this.timers,
    setPinnedFunction: this.setPinnedFunction.bind(this),
    setStateDesc: this.setStateDesc.bind(this),
    featureProvider: this.featureProvider,
    canvases: new Map(),
  };
  private pinnedFunction: FunctionValue | null = null;
  private pinned: Array<HTMLElement> = [];
  private stateDesc: string | null = null;
  private setPinnedFunction(pinnedFunction: FunctionValue | null) {
    this.pinnedFunction = pinnedFunction;
  }
  private setStateDesc(stateDesc: string | null) {
    this.stateDesc = stateDesc;
  }
  public constructor(
    private wtcdRoot: WTCDRoot,
    private random: Random,
    private featureProvider: FeatureProvider
  ) {
    this.sectionStack.push(this.wtcdRoot.sections[0]);
  }
  private scopes: Array<RuntimeScope> = [];
  private sectionStack: Array<Section> = [];
  private resolveVariableReference(variableName: string) {
    for (let i = this.scopes.length - 1; i >= 0; i--) {
      const variable = this.scopes[i].resolveVariableReference(variableName);
      if (variable !== null) {
        return variable;
      }
    }
    throw WTCDError.atUnknown(`Cannot resolve variable reference ` +
      `"${variableName}". This is most likely caused by WTCD compiler's ` +
      `error or the compiled output ` +
      `has been modified`);
  }
  /**
   * Iterate through the scopes and set the first register with registerName to
   * given value.
   *
   * @param registerName The name of register to look for
   * @param value The value to set to
   */
  private setRegister(registerName: RegisterName, value: RuntimeValue) {
    for (let i = this.scopes.length - 1; i >= 0; i--) {
      if (this.scopes[i].setRegisterIfExist(registerName, value)) {
        return;
      }
    }
    throw WTCDError.atUnknown(`Cannot resolve register reference "${registerName}". ` +
      `This is mostly likely caused by WTCD compiler's error or the compiled output ` +
      `has been modified`);
  }
  private getCurrentScope(): RuntimeScope {
    return this.scopes[this.scopes.length - 1];
  }
  private pushScope() {
    const scope = new RuntimeScope();
    this.scopes.push(scope);
    return scope;
  }
  private popScope() {
    return this.scopes.pop();
  }
  private evaluateChoiceExpression(expr: ChoiceExpression): ChoiceValue {
    const text = this.evaluator(expr.text);
    if (text.type !== 'string') {
      throw WTCDError.atLocation(expr, `First argument of choice is expected to be a string, ` +
        `received: ${describe(text)}`);
    }
    let action = this.evaluator(expr.action);
    if (action.type !== 'action' && action.type !== 'function' && action.type !== 'null') {
      throw WTCDError.atLocation(expr, `Second argument of choice is expected to be an action, a function, ` +
        `or null, received: ${describe(text)}`);
    }
    if (action.type === 'function') {
      action = {
        type: 'action',
        value: {
          action: 'function',
          fn: action,
          creator: expr,
        },
      };
    }
    return {
      type: 'choice',
      value: {
        text: text.value,
        action,
      },
    };
  }

  private evaluateConditionalExpression(expr: ConditionalExpression): RuntimeValue {
    const condition = this.evaluator(expr.condition);
    if (condition.type !== 'boolean') {
      throw WTCDError.atLocation(expr, `First argument of a conditional expression is expected to ` +
        `be a boolean, received: ${describe(condition)}`);
    }
    // Only evaluate the necessary branch
    if (condition.value) {
      return this.evaluator(expr.then);
    } else {
      return this.evaluator(expr.otherwise);
    }
  }

  private executeDeclarationStatement(expr: DeclarationStatement) {
    for (const singleDeclaration of expr.declarations) {
      let value: RuntimeValue;
      if (singleDeclaration.initialValue !== null) {
        value = this.evaluator(singleDeclaration.initialValue);
      } else {
        if (singleDeclaration.variableType === null) {
          value = getMaybePooled('null', null);
        } else if (singleDeclaration.variableType.length === 1) {
          switch (singleDeclaration.variableType[0]) {
            case 'boolean':
              value = getMaybePooled('boolean', false);
              break;
            case 'number':
              value = getMaybePooled('number', 0);
              break;
            case 'string':
              value = getMaybePooled('string', '');
              break;
            case 'list':
              value = { type: 'list', value: [] };
              break;
            default:
              throw WTCDError.atLocation(expr, `Variable type ` +
                `"${singleDeclaration.variableType[0]}" ` +
                `does not have a default initial value`);
          }
        } else if (singleDeclaration.variableType.includes('null')) {
          // Use null if null is allowed
          value = getMaybePooled('null', null);
        } else {
          throw WTCDError.atLocation(expr, `Variable type ` +
            `"${singleDeclaration.variableType.join(' ')}" does not have a ` +
            `default initial value`);
        }
      }
      if (!isTypeAssignableTo(value.type, singleDeclaration.variableType)) {
        throw WTCDError.atLocation(expr, `The type of variable ` +
          `${singleDeclaration.variableName} is ` +
          `${singleDeclaration.variableType}, thus cannot hold ` +
          `${describe(value)}`);
      }
      this.getCurrentScope().addVariable(singleDeclaration.variableName, {
        types: singleDeclaration.variableType,
        value,
      });
    }
  }

  private evaluateBlockExpression(expr: BlockExpression): RuntimeValue {
    const scope = this.pushScope();
    try {
      scope.addRegister('yield');
      for (const statement of expr.statements) {
        this.executeStatement(statement);
      }
      return scope.getRegister('yield')!;
    } catch (error) {
      if (
        (error instanceof BubbleSignal) &&
        (error.type === BubbleSignalType.YIELD)
      ) {
        return scope.getRegister('yield')!;
      }
      throw error;
    } finally {
      this.popScope();
    }
  }

  private evaluateSelectionExpression(expr: SelectionAction): ActionValue {
    const choicesList = this.evaluator(expr.choices);
    if (choicesList.type !== 'list') {
      throw WTCDError.atLocation(expr, `Expression after selection is ` +
        `expected to be a list of choices, received: ` +
        `${describe(choicesList)}`);
    }
    const choices = choicesList.value
      .filter(choice => choice.type !== 'null');
    for (let i = 0; i < choices.length; i++) {
      if (choices[i].type !== 'choice') {
        throw WTCDError.atLocation(expr, `Choice at index ${i} is expected ` +
          `to be a choice, received: ${describe(choices[i])}`);
      }
    }
    return {
      type: 'action',
      value: {
        action: 'selection',
        choices: choices as Array<Readonly<ChoiceValue>>,
      },
    };
  }

  private evaluateListExpression(expr: ListExpression): ListValue {
    return {
      type: 'list',
      value: flat(expr.elements.map(expr => {
        if (expr.type !== 'spread') {
          return this.evaluator(expr);
        }
        const list = this.evaluator(expr.expression);
        if (list.type !== 'list') {
          throw WTCDError.atLocation(expr, `Spread operator "..." can only ` +
            `be used before a list, received: ${describe(list)}`);
        }
        return list.value;
      })),
    };
  }

  private evaluateFunctionExpression(expr: FunctionExpression): FunctionValue {
    return {
      type: 'function',
      value: {
        fnType: 'wtcd',
        expr,
        captured: expr.captures.map(variableName => ({
          name: variableName,
          value: this.resolveVariableReference(variableName),
        })),
      },
    };
  }

  private evaluateSwitchExpression(expr: SwitchExpression): RuntimeValue {
    const switchValue = this.evaluator(expr.expression);
    for (const switchCase of expr.cases) {
      const matches = this.evaluator(switchCase.matches);
      if (matches.type !== 'list') {
        throw WTCDError.atLocation(switchCase.matches, `Value to match for ` +
          `each case is expected to be a list, received: ` +
          `${describe(matches)}`);
      }
      if (matches.value.some(oneMatch => isEqual(oneMatch, switchValue))) {
        // Matched
        return this.evaluator(switchCase.returns);
      }
    }
    // Default
    if (expr.defaultCase === null) {
      throw WTCDError.atLocation(expr, `None of the cases matched and no ` +
        `default case is provided`);
    } else {
      return this.evaluator(expr.defaultCase);
    }
  }

  private evaluateWhileExpression(expr: WhileExpression) {
    const scope = this.pushScope();
    scope.addRegister('break');
    let continueFlag = false;
    try { // Break
      while (true) {
        if (!continueFlag && expr.preExpr !== null) {
          try { // Continue
            this.evaluator(expr.preExpr);
          } catch (error) {
            if (!(
              (error instanceof BubbleSignal) &&
              (error.type === BubbleSignalType.CONTINUE)
            )) {
              throw error;
            }
          }
        }
        continueFlag = false;
        const whileCondition = this.evaluator(expr.condition);
        if (whileCondition.type !== 'boolean') {
          throw WTCDError.atLocation(expr, `Condition expression of a while ` +
            `loop is expected to return a boolean. Received: ` +
            `${describe(whileCondition)}`);
        }
        if (whileCondition.value === false) {
          break;
        }
        if (expr.postExpr !== null) {
          try { // Continue
            this.evaluator(expr.postExpr);
          } catch (error) {
            if (!(
              (error instanceof BubbleSignal) &&
              (error.type === BubbleSignalType.CONTINUE)
            )) {
              throw error;
            }
            continueFlag = true;
          }
        }
      }
    } catch (error) {
      if (
        (error instanceof BubbleSignal) &&
        (error.type === BubbleSignalType.BREAK)
      ) {
        return scope.getRegister('break')!;
      }
      throw error;
    } finally {
      this.popScope();
    }
    return scope.getRegister('break')!;
  }

  private evaluateIfExpression(expr: IfExpression) {
    const condition = this.evaluator(expr.condition);
    if (condition.type !== 'boolean') {
      throw WTCDError.atLocation(expr, `The condition of an if expression is ` +
        `expected to be a boolean. Received: ${describe(condition)}`);
    }
    if (condition.value) {
      return this.evaluator(expr.then);
    } else if (expr.otherwise !== null) {
      return this.evaluator(expr.otherwise);
    } else {
      return getMaybePooled('null', null);
    }
  }

  private evaluator(expr: Expression): RuntimeValue {
    switch (expr.type) {
      case 'unaryExpression':
        return unaryOperators.get(expr.operator)!.fn(
          expr,
          this.interpreterHandle,
        );
      case 'binaryExpression':
        return binaryOperators.get(expr.operator)!.fn(
          expr,
          this.interpreterHandle,
        );
      case 'booleanLiteral':
        return getMaybePooled('boolean', expr.value);
      case 'numberLiteral':
        return getMaybePooled('number', expr.value);
      case 'stringLiteral':
        return getMaybePooled('string', expr.value);
      case 'nullLiteral':
        return getMaybePooled('null', null);
      case 'list':
        return this.evaluateListExpression(expr);
      case 'choiceExpression':
        return this.evaluateChoiceExpression(expr);
      case 'conditionalExpression':
        return this.evaluateConditionalExpression(expr);
      case 'block':
        return this.evaluateBlockExpression(expr);
      case 'gotoAction':
        return {
          type: 'action',
          value: {
            action: 'goto',
            target: expr.sections,
          },
        };
      case 'exitAction':
        return {
          type: 'action',
          value: {
            action: 'exit',
          },
        };
      case 'selection':
        return this.evaluateSelectionExpression(expr);
      case 'variableReference':
        return this.resolveVariableReference(expr.variableName).value;
      case 'function':
        return this.evaluateFunctionExpression(expr);
      case 'switch':
        return this.evaluateSwitchExpression(expr);
      case 'while':
        return this.evaluateWhileExpression(expr);
      case 'if':
        return this.evaluateIfExpression(expr);
      case 'tag':
        return {
          type: 'list',
          value: [ getMaybePooled('string', expr.name) ],
        };
    }
  }

  private executeStatement(statement: Statement) {
    switch (statement.type) {
      case 'declaration':
        this.executeDeclarationStatement(statement);
        return;
      case 'expression':
        this.evaluator(statement.expression);
        return;
      case 'yield':
        this.setRegister('yield', this.evaluator(statement.value));
        throw new BubbleSignal(BubbleSignalType.YIELD); // Bubble up
      case 'setYield':
        this.setRegister('yield', this.evaluator(statement.value));
        return;
      case 'return':
        this.setRegister('return', this.evaluator(statement.value));
        throw new BubbleSignal(BubbleSignalType.RETURN);
      case 'setReturn':
        this.setRegister('return', this.evaluator(statement.value));
        return;
      case 'break':
        this.setRegister('break', this.evaluator(statement.value));
        throw new BubbleSignal(BubbleSignalType.BREAK);
      case 'setBreak':
        this.setRegister('break', this.evaluator(statement.value));
        return;
      case 'continue':
        throw new BubbleSignal(BubbleSignalType.CONTINUE);
    }
  }

  private addToSectionStack(sectionName: string) {
    for (const section of this.wtcdRoot.sections) {
      if (section.name === sectionName) {
        this.sectionStack.push(section);
        return;
      }
    }
    throw WTCDError.atUnknown(`Unknown section "${sectionName}"`);
  }

  private updatePinned() {
    if (this.pinnedFunction !== null) {
      try {
        invokeFunctionRaw(
          this.pinnedFunction.value,
          [],
          this.interpreterHandle,
        );
      } catch (error) {
        if (error instanceof FunctionInvocationError) {
          throw WTCDError.atUnknown(`Failed to invoke the pinned ` +
            `function (${describe(this.pinnedFunction)}): ` +
            error.message);
        } else if (error instanceof WTCDError) {
          error.pushWTCDStack(`Pinned function (` +
            `${describe(this.pinnedFunction)})`);
        }
        throw error;
      }
      this.pinned = this.currentlyBuilding;
      this.currentlyBuilding = [];
    } else if (this.pinned.length !== 0) {
      this.pinned = [];
    }
  }

  private *executeAction(action: ActionValue): Generator<ContentOutput, void, number> {
    switch (action.value.action) {
      case 'goto':
        for (let i = action.value.target.length - 1; i >= 0; i--) {
          this.addToSectionStack(action.value.target[i]);
        }
        break;
      case 'exit':
        // Clears the section stack so the scripts end immediately
        this.sectionStack.length = 0;
        break;
      case 'selection': {
        const choicesRaw = action.value.choices;
        const choices: Array<SingleChoice> = choicesRaw.map(choice => ({
          content: choice.value.text,
          disabled: choice.value.action.type === 'null',
        }));
        const yieldValue: ContentOutput = {
          content: this.currentlyBuilding,
          choices,
        };
        this.currentlyBuilding = [];

        this.updatePinned();

        // Hands over control so player can make a decision
        const playerChoiceIndex = yield yieldValue;
        const playerChoice = choicesRaw[playerChoiceIndex];
        if (playerChoice === undefined || playerChoice.value.action.type === 'null') {
          throw new InvalidChoiceError(playerChoiceIndex);
        }
        yield* this.executeAction(playerChoice.value.action);
        break;
      }
      case 'function': {
        let newAction: RuntimeValue;
        try {
          newAction = invokeFunctionRaw(
            action.value.fn.value,
            [], this.interpreterHandle,
          );
        } catch (error) {
          if (error instanceof FunctionInvocationError) {
            throw WTCDError.atLocation(
              action.value.creator,
              `Failed to evaluate the function action for this choice: ` +
                `${error.message}`,
            );
          } else if (error instanceof WTCDError) {
            error.pushWTCDStack(`Function action`, action.value.creator);
          }
          throw error;
        }
        if (newAction.type === 'action') {
          yield* this.executeAction(newAction);
        } else if (newAction.type !== 'null') {
          throw WTCDError.atLocation(action.value.creator, `Value returned ` +
            `an function action is expected to be an action or null. ` +
            `Received: ${describe(newAction)}`);
        }
        break;
      }
    }
  }

  private started = false;
  private sectionEnterTimes = new Map<string, number>();
  private currentlyBuilding: Array<HTMLElement> = [];
  private pushContent($element: HTMLElement) {
    this.currentlyBuilding.push($element);
  }
  private runSection(section: Section): RuntimeValue {
    const $mdHost = document.createElement('div');

    // Evaluate the executes clause
    if (section.executes !== null) {
      this.evaluator(section.executes);
    }

    /** Times this section has been entered including this time */
    const enterTime = this.sectionEnterTimes.has(section.name)
      ? this.sectionEnterTimes.get(section.name)! + 1
      : 1;
    this.sectionEnterTimes.set(section.name, enterTime);

    /** Content that fits within the bounds */
    const eligibleSectionContents = section.content.filter(
      content => (content.lowerBound === undefined || content.lowerBound <= enterTime) &&
        (content.upperBound === undefined || content.upperBound >= enterTime),
    );
    if (eligibleSectionContents.length !== 0) {
      const selectedContent = eligibleSectionContents[
        this.random.nextInt(0, eligibleSectionContents.length)
      ];
      $mdHost.innerHTML = selectedContent.html;

      // Parameterize
      for (const variable of selectedContent.variables) {
        ($mdHost.getElementsByClassName(variable.elementClass)[0] as HTMLSpanElement)
          .innerText = String(this.resolveVariableReference(variable.variableName).value.value);
      }
      let $current = $mdHost.firstChild;
      while ($current !== null) {
        if ($current instanceof HTMLElement) {
          this.pushContent($current);
        }
        $current = $current.nextSibling;
      }
    }
    return this.evaluator(section.then);
  }
  public getPinned() {
    return this.pinned;
  }
  public getStateDesc() {
    return this.stateDesc;
  }
  public *start(): Generator<ContentOutput, ContentOutput, number> {
    const stdScope = this.pushScope();
    for (const stdFunction of stdFunctions) {
      stdScope.addVariable(stdFunction.name, {
        types: ['function'],
        value: {
          type: 'function',
          value: {
            fnType: 'native',
            nativeFn: stdFunction,
          },
        },
      });
    }

    // Global scope
    this.pushScope();

    if (this.started) {
      throw new Error('Interpretation has already started.');
    }
    this.started = true;

    let lastSection: Section | null = null;

    try {
      // Initialization
      for (const statement of this.wtcdRoot.initStatements) {
        this.executeStatement(statement);
      }
      while (this.sectionStack.length !== 0) {
        const currentSection = this.sectionStack.pop()!;
        lastSection = currentSection;

        const then = this.runSection(currentSection);

        if (then.type === 'action') {
          yield* this.executeAction(then);
        } else if (then.type !== 'null') {
          throw WTCDError.atLocation(currentSection.then, `Expression after ` +
            `then is expected to return an action, or null, ` +
            `received: ${describe(then)}`);
        }
      }
    } catch (error) {
      if (error instanceof BubbleSignal) {
        throw WTCDError.atUnknown(`Uncaught BubbleSignal with type "${error.type}".`);
      }
      if (error instanceof WTCDError) {
        if (lastSection === null) {
          error.pushWTCDStack(`initialization`);
        } else {
          error.pushWTCDStack(`section "${lastSection.name}"`, lastSection);
        }
      }
      throw error;
    }
    const lastContent = {
      content: this.currentlyBuilding,
      choices: [],
    };
    this.updatePinned();
    return lastContent;
  }
}


code:
/**
 * Convert a string to 32 bit hash
 * https://stackoverflow.com/a/47593316
 */
function xmur3(strSeed: string) {
  let h = 1779033703 ^ strSeed.length;
  for (let i = 0; i < strSeed.length; i++) {
    h = Math.imul(h ^ strSeed.charCodeAt(i), 3432918353),
    h = h << 13 | h >>> 19;
  }
  return () => {
    h = Math.imul(h ^ h >>> 16, 2246822507);
    h = Math.imul(h ^ h >>> 13, 3266489909);
    return (h ^= h >>> 16) >>> 0;
  };
}

/**
 * Create a seeded random number generator using the four passed in 32 bit
 * number as seeds.
 * https://stackoverflow.com/a/47593316
 *
 * @param a seed
 * @param b seed
 * @param c seed
 * @param d seed
 * @returns seeded random number generator
 */
function sfc32(a: number, b: number, c: number, d: number) {
    return () => {
      a >>>= 0; b >>>= 0; c >>>= 0; d >>>= 0;
      let t = (a + b) | 0;
      a = b ^ b >>> 9;
      b = c + (c << 3) | 0;
      c = (c << 21 | c >>> 11);
      d = d + 1 | 0;
      t = t + d | 0;
      c = c + t | 0;
      return (t >>> 0) / 4294967296;
    };
}

export class Random {
  private rng: () => number;
  public constructor(seed: string) {
    const seedFn = xmur3(seed);
    this.rng = sfc32(seedFn(), seedFn(), seedFn(), seedFn());
  }
  public next(low = 0, high = 1) {
    return this.rng() * (high - low) + low;
  }
  public nextBool() {
    return this.rng() < 0.5;
  }
  public nextInt(low: number, high: number) {
    return Math.floor(this.next(low, high));
  }
}


code:
/**
 * This is used to generate ids for interpolated values in sections. Name
 * generator is used in order to prevent reader from learning all the variable
 * names simply by inspecting elements. Of course, reader can still read the
 * compiled tree structure or the source code for the corresponding passage.
 * However, that will involve slightly more effort.
 */
export class SimpleIdGenerator {
  private charPool!: Array<string>;
  private index: number = 0;
  private getName(index: number): string {
    if (index >= this.charPool.length) {
      return this.getName(Math.floor(index / this.charPool.length) - 1)
        + this.charPool[index % this.charPool.length];
    }
    return this.charPool[index];
  }
  public next() {
    if (this.charPool === undefined) {
      this.charPool = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'
        .split('')
        .sort(() => Math.random() - 0.5);
    }
    return this.getName(this.index++);
  }
}


code:
import { englishConcatenation } from './englishConcatenation';
import { operators } from './operators';
import { WTCDError } from './WTCDError';

/**
 * A stream of discrete items.
 */
abstract class ItemStream<T> {
  /** Whether the first element is read */
  private initCompleted: boolean = false;
  /** Element next #next() or #peek() call will return */
  private current: T | undefined;
  /**
   * Symbol for #readNext() implementation to indicate additional call is
   * required.
   */
  public static readonly RECALL = Symbol('recall');
  /**
   * Read next item. If this read failed to obtain an item, return
   * ItemStream.RECALL will result an additional call.
   * @return asd
   */
  protected abstract readNext(): T | undefined | typeof ItemStream.RECALL;
  /** Callback when next is called but next item does not exist */
  protected abstract onNextCallWhenEOF(): never;
  /** Calls readNext and handles ItemStream.RECALL */
  private readNextWithRecall() {
    this.initCompleted = true;
    let item;
    do {
      item = this.readNext();
    } while (item === ItemStream.RECALL);
    this.current = item;
    return this.current as T | undefined;
  }
  private ensureInitCompleted() {
    if (!this.initCompleted) {
      this.readNextWithRecall();
    }
  }
  public peek(): T | undefined {
    this.ensureInitCompleted();
    return this.current;
  }
  public next(): T {
    this.ensureInitCompleted();
    const item = this.current;
    if (item === undefined) {
      return this.onNextCallWhenEOF();
    }
    this.readNextWithRecall();
    return item;
  }
  public eof() {
    return this.peek() === undefined;
  }
}

/**
 * A steam of characters. Automatically handles inconsistency in line breaks and
 * line/column counting.
 */
class CharStream extends ItemStream<string> {
  public constructor(
    private source: string,
  ) {
    super();
  }
  private pointer: number = -1;
  private line: number = 1;
  private column: number = 0;
  protected readNext() {
    this.pointer++;
    const current = this.source[this.pointer];
    if (current === '\r' || current === '\n') {
      this.line++;
      this.column = 0;
      if (current === '\r' && (this.source[this.pointer + 1] === '\n')) { // Skip \r\n
        this.pointer++;
      }
      return '\n';
    }
    this.column++;
    return current;
  }
  protected onNextCallWhenEOF() {
    return this.throwUnexpectedNext();
  }
  public throw(message: string): never {
    throw WTCDError.atLineColumn(this.line, this.column, message);
  }
  public describeNext(): string {
    switch (this.peek()) {
      case undefined: return '<EOF>';
      case '\t': return '<TAB>';
      case '\n': return '<LF>';
      default: return '"' + this.peek() + '"';
    }
  }
  public throwUnexpectedNext(expecting?: string): never {
    if (expecting === undefined) {
      return this.throw(`Unexpected character ${this.describeNext()}`);
    } else {
      return this.throw(`Unexpected character ${this.describeNext()}, expecting ${expecting}`);
    }
  }
  public getLine(): number {
    return this.line;
  }
  public getColumn(): number {
    return this.column;
  }
  /** Reads next n chars without handling line breaks. */
  public peekNextNChars(n: number): string {
    return this.source.substr(this.pointer, n);
  }
}

function includes(from: string, char: string | undefined) {
  if (char === undefined) {
    return false;
  }
  return from.includes(char);
}

function isNumberPart(char: string | undefined) {
  return includes('0123456789.', char);
}

function isAtNumberStart(charStream: CharStream) {
  return includes('0123456789', charStream.peek()) || (
    (charStream.peek() === '.') &&
    (includes('0123456789', charStream.peekNextNChars(2)[1]))
  );
}

function isSpace(char: string | undefined) {
  return includes(' \t\n', char);
}

function isIdentifierStart(char: string | undefined) {
  return includes('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_', char);
}

function isIdentifierBody(char: string | undefined) {
  return includes('1234567890', char) || isIdentifierStart(char);
}

function isOperatorPart(char: string | undefined) {
  return includes('+-*/^&|=><!?:%~.', char);
}

function isPunctuation(char: string | undefined) {
  return includes('[](){}$', char);
}

function isStringQuote(char: string | undefined) {
  return includes('"\'`', char);
}

function isTagStart(char: string | undefined) {
  return char === '#';
}

function isTagBody(char: string | undefined) {
  return isIdentifierBody(char);
}

function isCommentStarter(str: string) {
  return ['//', '/*'].includes(str);
}

type TokenType = 'identifier' | 'keyword' | 'operator' | 'punctuation' | 'string' | 'number' | 'tag';

interface TokenContent {
  type: TokenType;
  content: string;
}

export type Token = TokenContent & {
  line: number;
  column: number;
};

const keywords = new Set([
  'declare',
  'section',
  'then',
  'goto',
  'null',
  'true',
  'false',
  'number',
  'boolean',
  'string',
  'action',
  'choice',
  'selection',
  'yield',
  'exit',
  'function',
  'return',
  'switch',
  'while',
  'do',
  'continue',
  'break',
  'if',
  'else',
  'list',

  // Reserved
  'for',
  'in',
  'of',
  'enum',
  'dict',
  'dictionary',
  'const',
  'mixed',
]);

const escapeMap = new Map([
  ['n', '\n'],
  ['t', '\t'],
  ['\'', '\''],
  ['"', '"'],
  ['`', '`'],
  ['\\', '\\'],
]);

export class TokenStream extends ItemStream<Token> {
  private charStream: CharStream;
  public constructor(
    source: string,
  ) {
    super();
    this.charStream = new CharStream(source);
  }

  public isNext(type: TokenType, content?: string | Array<string>) {
    const token = this.peek();
    if (token === undefined) {
      return false;
    }
    return (token.type === type) && (content === undefined || (Array.isArray(content)
      ? content.includes(token.content)
      : token.content === content));
  }

  private describeToken(token: Token) {
    return `${token.type} "${token.content}"`;
  }

  public assertNext(type: TokenType, content?: string | Array<string>) {
    if (!this.isNext(type, content)) {
      const expecting = content === undefined
        ? type
        : Array.isArray(content)
          ? `${type} ${englishConcatenation(content.map(item => `"${item}"`))}`
          : `${type} "${content}"`;
      return this.throwUnexpectedNext(expecting);
    }
    return this.peek()!;
  }

  public assertAndSkipNext(type: TokenType, content?: string | Array<string>) {
    this.assertNext(type, content);
    return this.next()!;
  }

  public throwUnexpectedNext(expecting?: string): never {
    const token = this.peek();
    if (token === undefined) {
      throw this.charStream.throwUnexpectedNext(expecting);
    }
    if (expecting === undefined) {
      throw WTCDError.atLineColumn(token.line, token.column, `Unexpected token ${this.describeToken(token)}`);
    } else {
      throw WTCDError.atLineColumn(token.line, token.column, `Unexpected token ${this.describeToken(token)}, expecting ${expecting}`);
    }
  }

  protected onNextCallWhenEOF(): never {
    throw WTCDError.atLineColumn(this.charStream.getLine(), this.charStream.getColumn(), 'Unexpected <EOF>');
  }

  /** Reads from charStream until predicate returns false for next char */
  private readWhile(predicate: (char: string | undefined) => boolean) {
    let result = '';
    while (predicate(this.charStream.peek())) {
      result += this.charStream.next();
    }
    return result;
  }

  /** Assuming next char is a part of an identifier, reads next identifier */
  private readIdentifier() {
    return this.charStream.next() + this.readWhile(isIdentifierBody);
  }

  private readTag() {
    this.charStream.next();
    return this.readWhile(isTagBody); // # <- is ignored
  }

  /** Assuming next char is a part of a number, reads next number */
  private readNumber() {
    let num = this.charStream.next();
    /** Whether dot appeared yet */
    let dot = num === '.';
    while (isNumberPart(this.charStream.peek())) {
      if (this.charStream.peek() === '.') {
        if (dot) {
          return this.charStream.throwUnexpectedNext('number');
        } else {
          dot = true;
        }
      }
      num += this.charStream.next();
    }
    return num;
  }

  /** Assuming next char is a part of an operator, reads next operator */
  private readOperator() {
    return this.charStream.next() + this.readWhile(isOperatorPart);
  }

  /** Assuming next char is a string quote, reads the string */
  private readString() {
    const quote = this.charStream.next();
    let str = '';
    while (this.charStream.peek() !== quote) {
      if (this.charStream.eof()) {
        this.charStream.throwUnexpectedNext(quote);
      }
      if (this.charStream.peek() !== '\\') {
        str += this.charStream.next();
      } else {
        this.charStream.next();
        const escaped = escapeMap.get(this.charStream.peek()!);
        if (escaped === undefined) {
          this.charStream.throw(`Unescapable character ${this.charStream.describeNext()}`);
        }
        str += escaped;
        this.charStream.next();
      }
    }
    this.charStream.next();
    return str;
  }

  /** Assuming next char is a comment starter ("/"), skips the entire comment */
  private skipComment() {
    this.charStream.next(); // Skips /
    if (this.charStream.peek() === '*') { // Block comment
      this.charStream.next(); // Skips *
      while (true) {
        while (this.charStream.peek() !== '*') {
          this.charStream.next();
        }
        this.charStream.next(); // Skips *
        if (this.charStream.next() === '/') { // Skips /
          return;
        }
      }
    } else if (this.charStream.peek() === '/') { // Line comment
      this.charStream.next(); // Skips the second /
      while (this.charStream.peek() !== '\n') {
        this.charStream.next();
      }
      this.charStream.next(); // Skips \n
    } else {
      this.charStream.throw(`Unknown comment type.`);
    }
  }

  protected readNext() {
    while (isSpace(this.charStream.peek())) {
      this.charStream.next();
    }
    if (this.charStream.eof()) {
      return undefined;
    }
    if (isCommentStarter(this.charStream.peekNextNChars(2))) {
      this.skipComment();
      return ItemStream.RECALL;
    }

    const line = this.charStream.getLine();
    const column = this.charStream.getColumn();
    let tokenContent: TokenContent;

    if (isIdentifierStart(this.charStream.peek())) {
      const identifier = this.readIdentifier();
      tokenContent = {
        type: keywords.has(identifier) ? 'keyword' : 'identifier',
        content: identifier,
      };
    } else if (isAtNumberStart(this.charStream)) {
      tokenContent = {
        type: 'number',
        content: this.readNumber(),
      };
    } else if (isOperatorPart(this.charStream.peek())) {
      tokenContent = {
        type: 'operator',
        content: this.readOperator(),
      };
      if (!operators.has(tokenContent.content)) {
        throw WTCDError.atLineColumn(line, column, `Unknown operator: "${tokenContent.content}"`);
      }
    } else if (isPunctuation(this.charStream.peek())) {
      tokenContent = {
        type: 'punctuation',
        content: this.charStream.next(),
      };
    } else if (isStringQuote(this.charStream.peek())) {
      tokenContent = {
        type: 'string',
        content: this.readString(),
      };
    } else if (isTagStart(this.charStream.peek())) {
      tokenContent = {
        type: 'tag',
        content: this.readTag(),
      };
    } else {
      return this.charStream.throwUnexpectedNext();
    }

    return {
      line,
      column,
      ...tokenContent,
    };
  }
}


code:
import { OptionalLocationInfo } from './types';

const empty = {};

export class WTCDError<TLocationInfo extends boolean> extends Error {
  private readonly wtcdStackArray: Array<string> = [];
  private constructor(
    message: string,
    public readonly line: TLocationInfo extends true ? number : null,
    public readonly column: TLocationInfo extends true ? number : null,
  ) {
    super(message);
    this.name = 'WTCDError';
  }
  public get wtcdStack() {
    return this.message + '\n' + this.wtcdStackArray.join('\n');
  }
  public pushWTCDStack(info: string, location: OptionalLocationInfo = empty) {
    this.wtcdStackArray.push(`    at ${info}`
      + (location.line
        ? ':' + location.line
          + (location.column
            ? ':' + location.column
            : '')
        : ''),
    );
  }
  public static atUnknown(message: string) {
    return new WTCDError<false>(message + ` at unknown location. (Location `
      + `info is not available for this type of error)`, null, null);
  }
  public static atLineColumn(line: number, column: number, message: string) {
    return new WTCDError<true>(
      message + ` at ${line}:${column}.`,
      line,
      column,
    );
  }
  public static atLocation(
    location: OptionalLocationInfo | null,
    message: string,
  ) {
    if (location === null) {
      return WTCDError.atUnknown(message);
    }
    if (location.line === undefined) {
      return new WTCDError(message + ' at unknown location. (Try recompile in '
        + 'debug mode to enable source map)', null, null);
    } else {
      return WTCDError.atLineColumn(location.line, location.column!, message);
    }
  }
}


code:
import { InterpreterHandle, RuntimeValue } from './Interpreter';
import { BinaryOperatorFn } from './operators';
import { BinaryExpression } from './types';
export function autoEvaluated(fn: (
  arg0: RuntimeValue,
  arg1: RuntimeValue,
  expr: BinaryExpression,
  interpreterHandle: InterpreterHandle,
) => RuntimeValue): BinaryOperatorFn {
  return (expr, interpreterHandle) => {
    const arg0 = interpreterHandle.evaluator(expr.arg0);
    const arg1 = interpreterHandle.evaluator(expr.arg1);
    return fn(arg0, arg1, expr, interpreterHandle);
  };
}


code:
import { BooleanValue, NullValue, NumberValue, RuntimeValue, RuntimeValueRaw, RuntimeValueType, Variable } from './Interpreter';

// Your typical immature optimization
// Caches null values, boolean values, and small integers to somewhat reduce GC

export const nullValue: NullValue = { type: 'null', value: null };
export const trueValue: BooleanValue = { type: 'boolean', value: true };
export const falseValue: BooleanValue = { type: 'boolean', value: false };
export const smallIntegers: Array<NumberValue> = [];
for (let i = 0; i <= 100; i++ ) {
  smallIntegers.push({
    type: 'number',
    value: i,
  });
}
export function booleanValue(value: boolean) {
  return value ? trueValue : falseValue;
}
export function getMaybePooled<T extends RuntimeValueType>(
  type: T,
  value: RuntimeValueRaw<T>,
): Extract<RuntimeValue, { type: T }> {
  if (type === 'null') {
    return nullValue as any;
  }
  if (type === 'boolean') {
    return booleanValue(value as boolean) as any;
  }
  if (type === 'number' && (value as number) >= 0 && (value as number) <= 100 && ((value as number) % 1 === 0)) {
    return smallIntegers[value as number] as any;
  }
  return {
    type,
    value,
  } as any;
}


code:
export function englishConcatenation(strings: Array<string>, relation = 'or') {
  switch (strings.length) {
    case 0: return '';
    case 1: return strings[0];
    case 2: return `${strings[0]} ${relation} ${strings[1]}`;
    default: return strings
      .slice()
      .splice(-1, 1, `${relation} ${strings[strings.length - 1]}`)
      .join(', ');
  }
}


code:
import { autoEvaluated } from './autoEvaluated';
import { getMaybePooled } from './constantsPool';
import { BubbleSignal, BubbleSignalType, describe, InterpreterHandle, isTypeAssignableTo, RuntimeValue, RuntimeValueRaw } from './Interpreter';
import { NativeFunctionError } from './std/utils';
import { BinaryExpression } from './types';
import { WTCDError } from './WTCDError';

export class FunctionInvocationError extends Error {
  public constructor(message: string) {
    super(message);
    Object.setPrototypeOf(this, new.target.prototype);
  }
}

export function invokeFunctionRaw(
  functionValue: RuntimeValueRaw<'function'>,
  args: ReadonlyArray<RuntimeValue>,
  interpreterHandle: InterpreterHandle,
): RuntimeValue {
  const {
    evaluator,
    pushScope,
    popScope,
  } = interpreterHandle;
  if (functionValue.fnType === 'native') {
    try {
      return functionValue.nativeFn(args, interpreterHandle);
    } catch (error) {
      if (error instanceof NativeFunctionError) {
        // Wrap up native function errors
        throw new FunctionInvocationError(`Failed to call native function ` +
          `"${functionValue.nativeFn.name}". Reason: ${error.message}`);
      } else {
        throw error;
      }
    }
  } else if (functionValue.fnType === 'wtcd') {
    const scope = pushScope();
    try {
      scope.addRegister('return');
      // Check and add arguments to the scope
      functionValue.expr.arguments.forEach((argument, index) => {
        let value = args[index];
        if (value === undefined || value.type === 'null') {
          // Use default
          if (argument.defaultValue !== null) {
            value = evaluator(argument.defaultValue);
          } else if (isTypeAssignableTo('null', argument.type)) {
            value = getMaybePooled('null', null);
          } else {
            throw new FunctionInvocationError(`The argument with index = ` +
              `${index} of invocation is omitted, but it does not have a ` +
              `default value and it does not allow null values`);
          }
        }
        if (!isTypeAssignableTo(value.type, argument.type)) {
          throw new FunctionInvocationError(`The argument with index = ` +
            `${index} of invocation has wrong type. Expected: ` +
            `${argument.type}, received: ${describe(value)}`);
        }
        scope.addVariable(argument.name, {
          types: [value.type],
          value,
        });
      });
      // Rest arg
      if (functionValue.expr.restArgName !== null) {
        scope.addVariable(
          functionValue.expr.restArgName, {
            types: ['list'],
            value: {
              type: 'list',
              value: args.slice(functionValue.expr.arguments.length),
            },
          },
        );
      }
      // Restore captured variables
      functionValue.captured.forEach(captured => {
        scope.addVariable(
          captured.name,
          captured.value,
        );
      });
      // Invoke function
      const evaluatedValue = evaluator(functionValue.expr.expression);
      const registerValue = scope.getRegister('return')!;
      // Prioritize register value
      if (registerValue.type === 'null') {
        // Only use evaluated value if no return or setReturn statement is
        // executed.
        return evaluatedValue;
      } else {
        return registerValue;
      }
    } catch (error) {
      if (
        (error instanceof BubbleSignal) &&
        (error.type === BubbleSignalType.RETURN)
      ) {
        return scope.getRegister('return')!;
      }
      throw error;
    } finally {
      popScope();
    }
  } else {
    let fn: RuntimeValueRaw<'function'> = functionValue;
    const leftApplied = [];
    const rightApplied = [];
    do {
      if (fn.isLeft) {
        leftApplied.unshift(...fn.applied);
      } else {
        rightApplied.push(...fn.applied);
      }
      fn = fn.targetFn.value;
    } while (fn.fnType === 'partial');
    return invokeFunctionRaw(
      fn,
      [...leftApplied, ...args, ...rightApplied],
      interpreterHandle,
    );
  }
}

function handleError(expr: BinaryExpression, error: any): never {
  if (error instanceof FunctionInvocationError) {
    throw WTCDError.atLocation(expr, error.message);
  } else if (error instanceof WTCDError) {
    error.pushWTCDStack(`"${expr.operator}" invocation`, expr);
  }
  throw error;
}

export const regularInvocationRaw = (
  arg0: RuntimeValue,
  arg1: RuntimeValue,
  expr: BinaryExpression,
  interpreterHandle: InterpreterHandle,
) => {
  if (arg0.type !== 'function') {
    throw WTCDError.atLocation(expr, `Left side of function invocation ` +
      `"${expr.operator}" is expected to be a function, received: ` +
      `${describe(arg0)}`);
  }
  if (arg1.type !== 'list') {
    throw WTCDError.atLocation(expr, `Right side of function invocation ` +
      `"${expr.operator}" is expected to be a list, received: ` +
      `${describe(arg1)}`);
  }
  try {
    return invokeFunctionRaw(arg0.value, arg1.value, interpreterHandle);
  } catch (error) {
    return handleError(expr, error);
  }
};

export const regularInvocation = autoEvaluated(regularInvocationRaw);

export const pipelineInvocation = autoEvaluated((
  arg0,
  arg1,
  expr,
  interpreterHandle,
) => {
  if (arg1.type !== 'function') {
    throw WTCDError.atLocation(expr, `Right side of pipeline invocation "|>" ` +
      `is expected to be a function, received: ${describe(arg1)}`);
  }
  try {
    return invokeFunctionRaw(arg1.value, [ arg0 ], interpreterHandle);
  } catch (error) {
    return handleError(expr, error);
  }
});

export const reverseInvocation = autoEvaluated((
  arg0,
  arg1,
  expr,
  interpreterHandle,
) => {
  if (arg0.type !== 'list') {
    throw WTCDError.atLocation(expr, `Left side of reverse invocation "|::" ` +
      `is expected to be a list, received: ${describe(arg0)}`);
  }
  if (arg1.type !== 'function') {
    throw WTCDError.atLocation(expr, `Right side of reverse invocation "|::" ` +
      `is expected to be a function, received: ${describe(arg1)}`);
  }
  try {
    return invokeFunctionRaw(arg1.value, arg0.value, interpreterHandle);
  } catch (error) {
    return handleError(expr, error);
  }
});


code:
// This file defines all infix and prefix operators in WTCD.

import { autoEvaluated } from './autoEvaluated';
import { getMaybePooled } from './constantsPool';
import { assignValueToVariable, describe, InterpreterHandle, isEqual, RuntimeValue, RuntimeValueRaw, RuntimeValueType } from './Interpreter';
import {pipelineInvocation, regularInvocation, regularInvocationRaw, reverseInvocation} from './invokeFunction';
import { BinaryExpression, UnaryExpression } from './types';
import { WTCDError } from './WTCDError';

export type UnaryOperator = '-' | '!';

type UnaryOperatorFn = (expr: UnaryExpression, interpreterHandle: InterpreterHandle) => RuntimeValue;

export interface UnaryOperatorDefinition {
  precedence: number;
  fn: UnaryOperatorFn;
}

export const unaryOperators = new Map<string, UnaryOperatorDefinition>([
  ['-', {
    precedence: 17,
    fn: (expr, { evaluator }) => {
      const arg = evaluator(expr.arg);
      if (arg.type !== 'number') {
        throw WTCDError.atLocation(expr, `Unary operator "-" can only be applied ` +
          `to a number, received: ${describe(arg)}`);
      }
      return getMaybePooled('number', -arg.value);
    },
  }],
  ['!', {
    precedence: 17,
    fn: (expr, { evaluator }) => {
      const arg = evaluator(expr.arg);
      if (arg.type !== 'boolean') {
        throw WTCDError.atLocation(expr, `Unary operator "!" can only be applied ` +
          `to a boolean, received: ${describe(arg)}`);
      }
      return getMaybePooled('boolean', !arg.value);
    },
  }],
]);

export type BinaryOperator = '+' | '-' | '*' | '/' | '~/' | '%' | '=' | '+=' | '-=' | '*=' | '/=' | '~/=' | '%=';

export type BinaryOperatorFn = (
  expr: BinaryExpression,
  interpreterHandle: InterpreterHandle,
) => RuntimeValue;
export interface BinaryOperatorDefinition {
  precedence: number;
  fn: BinaryOperatorFn;
}

function autoEvaluatedSameTypeArg<TArg extends RuntimeValueType, TReturn extends RuntimeValueType>(
  argType: TArg,
  returnType: TReturn,
  fn: (
    arg0Raw: RuntimeValueRaw<TArg>,
    arg1Raw: RuntimeValueRaw<TArg>,
    expr: BinaryExpression,
    interpreterHandle: InterpreterHandle,
  ) => RuntimeValueRaw<TReturn>,
): BinaryOperatorFn {
  return autoEvaluated((arg0, arg1, expr, interpreterHandle) => {
    if (arg0.type === argType && arg1.type === argType) {
      // TypeScript is not smart enough to do the conversion here
      return getMaybePooled(
        returnType,
        fn(
          arg0.value as RuntimeValueRaw<TArg>,
          arg1.value as RuntimeValueRaw<TArg>,
          expr,
          interpreterHandle,
        ),
      );
    } else {
      throw WTCDError.atLocation(expr, `Binary operator "${expr.operator}" can only be ` +
        `applied to two ${argType}s, received: ${describe(arg0)} (left) and ` +
        `${describe(arg1)} (right)`);
    }
  });
}
function opAssignment<T0 extends RuntimeValueType, T1 extends RuntimeValueType>(
  arg0Type: T0, // Type of the variable
  arg1Type: T1,
  fn: (
    arg0Raw: RuntimeValueRaw<T0>,
    arg1Raw: RuntimeValueRaw<T1>,
    expr: BinaryExpression,
    interpreterHandle: InterpreterHandle,
  ) => RuntimeValueRaw<T0>,
): BinaryOperatorFn {
  return (expr, interpreterHandle) => {
    if (expr.arg0.type !== 'variableReference') {
        throw WTCDError.atLocation(expr, `Left side of binary operator "${expr.operator}" ` +
          `has to be a variable reference`);
    }
    const varRef = interpreterHandle.resolveVariableReference(expr.arg0.variableName);
    if (varRef.value.type !== arg0Type) {
      throw WTCDError.atLocation(expr, `Left side of binary operator "${expr.operator}" has to be a ` +
        `variable of type ${arg0Type}, actual type: ${varRef.value.type}`);
    }
    const arg1 = interpreterHandle.evaluator(expr.arg1);
    if (arg1.type !== arg1Type) {
      throw WTCDError.atLocation(expr, `Right side of binary operator "${expr.operator}" ` +
        ` has to be a ${arg1Type}, received: ${describe(arg1)}`);
    }
    const newValue = getMaybePooled(arg0Type, fn(
      varRef.value.value as RuntimeValueRaw<T0>,
      arg1.value as RuntimeValueRaw<T1>,
      expr,
      interpreterHandle,
    ));
    varRef.value = newValue;
    return newValue;
  };
}
export const binaryOperators = new Map<string, BinaryOperatorDefinition>([
  ['=', {
    precedence: 3,
    fn: (expr, { evaluator, resolveVariableReference }) => {
      if (expr.arg0.type !== 'variableReference') {
        throw WTCDError.atLocation(expr, `Left side of binary operator "=" has to be a ` +
          `variable reference`);
      }
      const varRef = resolveVariableReference(expr.arg0.variableName);
      const arg1 = evaluator(expr.arg1);
      assignValueToVariable(varRef, arg1, expr, expr.arg0.variableName);
      return arg1;
    },
  }],
  ['+=', {
    precedence: 3,
    fn: (expr, { evaluator, resolveVariableReference }) => {
      if (expr.arg0.type !== 'variableReference') {
        throw WTCDError.atLocation(expr, `Left side of binary operator "+=" ` +
          `has to be a variable reference`);
      }
      const varRef = resolveVariableReference(expr.arg0.variableName);
      if (varRef.value.type !== 'string' && varRef.value.type !== 'number') {
        throw WTCDError.atLocation(expr, `Left side of binary operator "+=" has to be a ` +
          `variable of type number or string, actual type: ${varRef.value.type}`);
      }
      const arg1 = evaluator(expr.arg1);
      if (arg1.type !== varRef.value.type) {
        throw WTCDError.atLocation(expr, `Right side of binary operator "+=" has to ` +
          ` be a ${varRef.value.type}, received: ${describe(arg1)}`);
      }
      const newValue = getMaybePooled(
        varRef.value.type,
        (varRef.value.value as any) + arg1.value,
      );
      varRef.value = newValue;
      return newValue;
    },
  }],
  ['-=', {
    precedence: 3,
    fn: opAssignment('number', 'number', (arg0Raw, arg1Raw) => arg0Raw - arg1Raw),
  }],
  ['*=', {
    precedence: 3,
    fn: opAssignment('number', 'number', (arg0Raw, arg1Raw) => arg0Raw * arg1Raw),
  }],
  ['/=', {
    precedence: 3,
    fn: opAssignment('number', 'number', (arg0Raw, arg1Raw) => arg0Raw / arg1Raw),
  }],
  ['~/=', {
    precedence: 3,
    fn: opAssignment('number', 'number', (arg0Raw, arg1Raw) => Math.trunc(arg0Raw / arg1Raw)),
  }],
  ['%=', {
    precedence: 3,
    fn: opAssignment('number', 'number', (arg0Raw, arg1Raw) => arg0Raw % arg1Raw),
  }],
  ['|>', {
    precedence: 5,
    fn: pipelineInvocation,
  }],
  ['|::', {
    precedence: 5,
    fn: reverseInvocation,
  }],
  ['||', {
    precedence: 6,
    fn: (expr, { evaluator }) => {
      const arg0 = evaluator(expr.arg0);
      if (arg0.type !== 'boolean') {
        throw WTCDError.atLocation(expr, `Left side of binary operator "||" has to be a boolean. ` +
          `Received ${describe(arg0)}`);
      }
      if (arg0.value === true) {
        return getMaybePooled('boolean', true);
      }
      const arg1 = evaluator(expr.arg1); // Short-circuit evaluation
      if (arg1.type !== 'boolean') {
        throw WTCDError.atLocation(expr, `Right side of binary operator "||" has to be a boolean. ` +
          `Received ${describe(arg1)}`);
      }
      return getMaybePooled('boolean', arg1.value);
    },
  }],
  ['?!', {
    precedence: 6,
    fn: (expr, { evaluator }) => {
      const arg0 = evaluator(expr.arg0);
      if (arg0.type !== 'null') {
        return arg0;
      }
      const arg1 = evaluator(expr.arg1);
      if (arg1.type === 'null') {
        throw WTCDError.atLocation(expr, `Right side of binary operator "?!" ` +
          `cannot be null. If returning null is desired in this case, please ` +
          `use "??" instead`);
      }
      return arg1;
    },
  }],
  ['??', {
    precedence: 6,
    fn: (expr, { evaluator }) => {
      const arg0 = evaluator(expr.arg0);
      if (arg0.type !== 'null') {
        return arg0;
      }
      return evaluator(expr.arg1);
    },
  }],
  ['&&', {
    precedence: 7,
    fn: (expr, { evaluator }) => {
      const arg0 = evaluator(expr.arg0);
      if (arg0.type !== 'boolean') {
        throw WTCDError.atLocation(expr, `Left side of binary operator "&&" has to be a boolean. ` +
          `Received ${describe(arg0)}`);
      }
      if (arg0.value === false) {
        return getMaybePooled('boolean', false);
      }
      const arg1 = evaluator(expr.arg1); // Short-circuit evaluation
      if (arg1.type !== 'boolean') {
        throw WTCDError.atLocation(expr, `Right side of binary operator "&&" has to be a boolean. ` +
          `Received ${describe(arg1)}`);
      }
      return getMaybePooled('boolean', arg1.value);
    },
  }],
  ['==', {
    precedence: 11,
    fn: autoEvaluated((arg0, arg1) => (getMaybePooled(
      'boolean',
      isEqual(arg0, arg1),
    ))),
  }],
  ['!=', {
    precedence: 11,
    fn: autoEvaluated((arg0, arg1) => (getMaybePooled(
      'boolean',
      !isEqual(arg0, arg1),
    ))),
  }],
  ['<', {
    precedence: 12,
    fn: autoEvaluatedSameTypeArg('number', 'boolean', (arg0Raw, arg1Raw) => arg0Raw < arg1Raw),
  }],
  ['<=', {
    precedence: 12,
    fn: autoEvaluatedSameTypeArg('number', 'boolean', (arg0Raw, arg1Raw) => arg0Raw <= arg1Raw),
  }],
  ['>', {
    precedence: 12,
    fn: autoEvaluatedSameTypeArg('number', 'boolean', (arg0Raw, arg1Raw) => arg0Raw > arg1Raw),
  }],
  ['>=', {
    precedence: 12,
    fn: autoEvaluatedSameTypeArg('number', 'boolean', (arg0Raw, arg1Raw) => arg0Raw >= arg1Raw),
  }],
  ['+', {
    precedence: 14,
    fn: autoEvaluated((arg0, arg1, expr) => {
      if (arg0.type === 'number' && arg1.type === 'number') {
        return getMaybePooled(
          'number',
          arg0.value + arg1.value,
        );
      } else if (arg0.type === 'string' && arg1.type === 'string') {
        return getMaybePooled(
          'string',
          arg0.value + arg1.value,
        );
      } else {
        throw WTCDError.atLocation(expr, `Binary operator "+" can only be applied to two ` +
          `strings or two numbers, received: ${describe(arg0)} (left) and ` +
          `${describe(arg1)} (right)`);
      }
    }),
  }],
  ['-', {
    precedence: 14,
    fn: autoEvaluatedSameTypeArg('number', 'number', (arg0Raw, arg1Raw) => arg0Raw - arg1Raw),
  }],
  ['*', {
    precedence: 15,
    fn: autoEvaluatedSameTypeArg('number', 'number', (arg0Raw, arg1Raw) => arg0Raw * arg1Raw),
  }],
  ['/', {
    precedence: 15,
    fn: autoEvaluatedSameTypeArg('number', 'number', (arg0Raw, arg1Raw) => arg0Raw / arg1Raw),
  }],
  ['~/', {
    precedence: 15,
    fn: autoEvaluatedSameTypeArg('number', 'number', (arg0Raw, arg1Raw) => Math.trunc(arg0Raw / arg1Raw)),
  }],
  ['%', {
    precedence: 15,
    fn: autoEvaluatedSameTypeArg('number', 'number', (arg0Raw, arg1Raw) => arg0Raw % arg1Raw),
  }],
  ['**', {
    precedence: 16,
    fn: autoEvaluatedSameTypeArg('number', 'number', (arg0Raw, arg1Raw) => arg0Raw ** arg1Raw),
  }],
  ['.', {
    precedence: 19,
    fn: autoEvaluated((arg0, arg1, expr) => {
      if (arg0.type !== 'list') {
        throw WTCDError.atLocation(expr, `Left side of binary operator "." ` +
          `is expected to be a list. Received: ${describe(arg0)}`);
      }
      if (arg1.type !== 'number' || arg1.value % 1 !== 0) {
        throw WTCDError.atLocation(expr, `Right side of binary operator "." ` +
          `is expected to be an integer. Received: ${describe(arg1)}`);
      }
      const value = arg0.value[arg1.value];
      if (value === undefined) {
        throw WTCDError.atLocation(expr, `List does not have an element at ` +
          `${arg1.value}. If return null is desired, use ".?" instead. List ` +
          `contents: ${describe(arg0)}`);
      }
      return value;
    }),
  }],
  ['.?', {
    precedence: 19,
    fn: autoEvaluated((arg0, arg1, expr) => {
      if (arg0.type !== 'list') {
        throw WTCDError.atLocation(expr, `Left side of binary operator ".?" ` +
          `is expected to be a list. Received: ${describe(arg0)}`);
      }
      if (arg1.type !== 'number' || arg1.value % 1 !== 0) {
        throw WTCDError.atLocation(expr, `Right side of binary operator ".?" ` +
          `is expected to be an integer. Received: ${describe(arg1)}`);
      }
      const value = arg0.value[arg1.value];
      if (value === undefined) {
        return getMaybePooled('null', null);
      }
      return value;
    }),
  }],
  ['?.', {
    precedence: 19,
    fn: (expr, { evaluator }) => {
      const arg0 = evaluator(expr.arg0);
      if (arg0.type === 'null') {
        return arg0; // Short circuit
      }
      if (arg0.type !== 'list') {
        throw WTCDError.atLocation(expr, `Left side of binary operator "?." ` +
          `is expected to be a list or null. Received: ${describe(arg0)}`);
      }
      const arg1 = evaluator(expr.arg1);
      if (arg1.type !== 'number' || arg1.value % 1 !== 0) {
        throw WTCDError.atLocation(expr, `Right side of binary operator "?." ` +
          `is expected to be an integer. Received: ${describe(arg1)}`);
      }
      const value = arg0.value[arg1.value];
      if (value === undefined) {
        throw WTCDError.atLocation(expr, `List does not have an element at ` +
          `${arg1.value}. If return null is desired, use "?.?" instead. ` +
          `List contents: ${describe(arg0)}`);
      }
      return value;
    },
  }],
  ['?.?', {
    precedence: 19,
    fn: (expr, { evaluator }) => {
      const arg0 = evaluator(expr.arg0);
      if (arg0.type === 'null') {
        return arg0; // Short circuit
      }
      if (arg0.type !== 'list') {
        throw WTCDError.atLocation(expr, `Left side of binary operator "?.?" ` +
          `is expected to be a list or null. Received: ${describe(arg0)}`);
      }
      const arg1 = evaluator(expr.arg1);
      if (arg1.type !== 'number' || arg1.value % 1 !== 0) {
        throw WTCDError.atLocation(expr, `Right side of binary operator ` +
          `"?.?" is expected to be an integer. Received: ${describe(arg1)}`);
      }
      const value = arg0.value[arg1.value];
      if (value === undefined) {
        return getMaybePooled('null', null);
      }
      return value;
    },
  }],
  ['::', {
    precedence: 20,
    fn: regularInvocation,
  }],
  ['?::', {
    precedence: 20,
    fn: (expr, interpreterHandle) => {
      const { evaluator } = interpreterHandle;
      const arg0 = evaluator(expr.arg0);
      if (arg0.type === 'null') {
        return arg0; // Short circuit
      }
      return regularInvocationRaw(
        arg0,
        evaluator(expr.arg1),
        expr,
        interpreterHandle,
      );
    },
  }],
  ['.:', {
    precedence: 20,
    fn: autoEvaluated((arg0, arg1, expr) => {
      if (arg0.type !== 'function') {
        throw WTCDError.atLocation(expr, `Left side of binary operator ".:" ` +
          `is expected to be a function. Received: ${describe(arg0)}`);
      }
      if (arg1.type !== 'list') {
        throw WTCDError.atLocation(expr, `Right side of binary operator ".:" ` +
          `is expected to be a list. Received: ${describe(arg1)}`);
      }
      if (arg1.value.length === 0) {
        return arg0;
      }
      return {
        type: 'function',
        value: {
          fnType: 'partial',
          isLeft: false,
          applied: arg1.value,
          targetFn: arg0,
        },
      };
    }),
  }],
  [':.', {
    precedence: 20,
    fn: autoEvaluated((arg0, arg1, expr) => {
      if (arg0.type !== 'function') {
        throw WTCDError.atLocation(expr, `Left side of binary operator ":." ` +
          `is expected to be a function. Received: ${describe(arg0)}`);
      }
      if (arg1.type !== 'list') {
        throw WTCDError.atLocation(expr, `Right side of binary operator ":." ` +
          `is expected to be a list. Received: ${describe(arg1)}`);
      }
      if (arg1.value.length === 0) {
        return arg0;
      }
      return {
        type: 'function',
        value: {
          fnType: 'partial',
          isLeft: true,
          applied: arg1.value,
          targetFn: arg0,
        },
      };
    }),
  }],
]);

export const conditionalOperatorPrecedence = 4;

export const operators = new Set([...unaryOperators.keys(), ...binaryOperators.keys(), '?', ':', '...']);