/**
 * FRISE (FRee Interactive Story Engine)
 * A light-weight engine for writing interactive fiction and games.
 *
 * Copyright 2022-2024 Christopher Pollett chris@pollett.org
 *
 * @license
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/.
 *
 * @file The JS code used to manage a FRISE game
 * @author Chris Pollett
 * @link https://www.frise.org/
 * @copyright 2022 - 2024
 */
/*
 * Global Variables
 */
/**
 * Contains {locale => {to_translate_id => translation}} for each
 * item in the game engine that needs to be localized.
 * There are not too many strings to translate. For a particular game you
 * could tack on to this tl variable after you load game.js in a script
 * tag before you call initGame.
 * @type {Object}
 */
var tl = {
    "en": {
        "init_slot_states_load" : "Load",
        "init_slot_states_save" : "Save",
        "restore_state_invalid_game" :
            "Does not appear to be a valid game save",
        "move_object_failed" : "moveObject(object_id, location) failed.\n" +
            "Either the object or location does not exist.\n",
        "slide_titles" : "Slide Titles"
    }
};
/**
 * Locale to use when looking up strings to be output in a particular language.
 * We set the default here, but a particular game can override this in
 * its script tag before calling initGame()
 * @type {string}
 */
var locale = "en";
/**
 * Flag to set the text direction of the game to right-to-left.
 * (if false, the text direction is left-to-right)
 * @type {boolean}
 */
var is_right_to_left = false;
/**
 * Global game object used to track one interactive story fiction game
 * @type {Game}
 */
var game;
/**
 * Flag used to tell if current user is interacting on a mobile device or not
 * @type {boolean}
 */
var is_mobile = window.matchMedia("(max-width: 1000px)").matches;
/**
 * Number of x-include tags left to load before game can run
 * @type {int}
 */
var includes_to_load = 0;
/**
 * Used as part of the closure of makeGameObject so that every game
 * object has an integer id.
 * @type {number}
 */
var object_counter = 0;
/**
 * Common Global Functions
 */
/**
 * Returns an Element object from the current document which has the provided
 * id.
 * @param {string} id of element trying to get an object for
 * @return {Element} desired element, if exists, else null
 */
function elt(id)
{
    return document.getElementById(id);
}
/**
 * Returns a collection of objects for Element's that match the provided Element
 * name in the current document.
 *
 * @param {string} name of HTML/XML Element wanted from current document
 * @return {HTMLCollection} of matching Element's
 */
function tag(name)
{
    return document.getElementsByTagName(name);
}
/**
 * Returns a list of Node's which match the CSS selector string provided.
 *
 * @param {string} a CSS selector string to match against current document
 * @return {NodeList} of matching Element's
 */
function sel(selector)
{
    return document.querySelectorAll(selector);
}
/**
 * Returns a game object based on the element in the current document of the
 * provided id.
 *
 * @param {string} id of element in current document.
 * @return {Object} a game object.
 */
function xelt(id)
{
    return makeGameObject(elt(id));
}
/**
 * Returns an array of game objects based on the elements in the current
 * document that match the CSS selector passed to it.
 *
 * @param {string} a CSS selector for objects in the current document
 * @return {Array} of game objects based on the tags that matched the selector
 */
function xsel(selector)
{
    let tag_objects = sel(selector);
    return makeGameObjects(tag_objects);
}
/**
 * Returns an array of game objects based on the elements in the current
 * document of the given tag name.
 *
 * @param {string} a name of a tag such as x-location or x-object.
 * @return {Array} of game objects based on the tags that matched the selector
 */
function xtag(name)
{
    let tag_objects = tag(name);
    return makeGameObjects(tag_objects);
}
/**
 * Sleep time many milliseconds before continuing to execute whatever code was
 * executing. This function returns a Promise so needs to be executed with await
 * so that the code following the sleep statement will be used to resolve the
 * Promise
 *
 * @param {number} time time in milliseconds to sleep for
 * @return {Promise} a promise whose resolve callback is to be executed after
 *  that many milliseconds.
 */
function sleep(time)
{
  return new Promise(resolve => setTimeout(resolve, time));
}
/**
 * Adds a link with link text message which when clicked will allow the
 * rendering of a Location to continue.
 * @param {string} message link text for the link that is pausing the rendering
 *  of the current location.
 * @param {string} any html that should appear after the proceed link
 * @return {Promise} which will be resolved which the link is clicked.
 */
function clickProceed(message, rest = "")
{
    let game_content = elt("game-content");
    game.tick++;
    game_content.innerHTML +=
        `<a id='click-proceed${game.tick}' href=''>${message}</a>${rest}`;
    return new Promise(resolve =>
        elt(`click-proceed${game.tick}`).addEventListener("click", resolve));
}

/**
 *  Given a rectangle within an Image object of a DollSlot doll_part
 *  specified by a point x,y and a width and a height. Scales it to a
 *  corresponding rectangle in the DollSlot coordinates (coordinates
 *  used to specify where it is within a Doll).
 *
 *  @param {int} x coordinate of the image rectangle
 *  @param {int} y coordinate of the image rectangle
 *  @param {int} width of the image rectangle
 *  @param {int} height of the image rectangle
 *  @param {DollSlot} doll_part with respect to which the coordinate
 *      transformation is done
 *  @param {Array<int>} of length 4 containing output [x, y, width, height]
 */
 function imageCoordinates(x, y, width, height, doll_part)
 {
     let scale_x = doll_part.image.width/parseInt(doll_part.width);
     let scale_y = doll_part.image.height/parseInt(doll_part.height);
     let part_x = parseInt(doll_part.x ?? 0);
     let part_y = parseInt(doll_part.y ?? 0);
     let out_x = Math.floor(scale_x * (x - part_x));
     let out_y = Math.floor(scale_y * (y - part_y));
     let out_width = Math.floor(scale_x * width);
     let out_height = Math.floor(scale_y * height);
     return [out_x, out_y, out_width, out_height];
 }

/**
 * Given an array of arrays [Object, property_name1, property_name2, ...]
 * Parses the property names to int's in the given object. If the
 * this results in Not a Number (NaN) then sets the property to the
 * provided default value
 *
 * @param Array<Array<Object, String...>>
 * @param int default_value

 */
function parseObjectsInts(objects_ints, default_value = 0)
{
    for (const object_ints of objects_ints) {
        let obj = object_ints[0];
        for (field_name of object_ints[1]) {
            let convert = obj[field_name];
            convert = parseInt(convert);
            obj[field_name] = (isNaN(convert)) ? default_value : convert;
        }
    }
}
/**
 * Creates from an HTMLCollection or Nodelist of x-object or x-location
 * elements an array of Location's or Object's suitable for a FRISE Game
 * object. Elements of the collection whose name aren't of the form x-
 * or which don't have id's are ignored. Otherwise, the id field of
 * the output Object or Location is set to the value of the x-object or
 * x-location's id. Details about how a suitable FRISE Game object is created
 * can be found @see makeGameObject()
 *
 * @param {HTMLCollection|Nodelist} tag_objects collection to be converted into
 *  an array FRISE game objects or location objects.
 * @return {Array} an array of FRISE game or location objects.
 */
function makeGameObjects(tag_objects)
{
    let game_objects = {};
    for (const tag_object of tag_objects) {
        let game_object = makeGameObject(tag_object);
        if (game_object && game_object.id) {
            game_objects[game_object.id] = game_object;
        }
    }
    return game_objects;
}
/**
 * Upper cases first letter of a string
 * @param {string} str string to upper case the first letter of
 * @return {string} result of upper-casing
 */
function upperFirst(str)
{
    if (!str) {
        return "";
    }
    let upper_first = str.charAt(0).toUpperCase();
    return upper_first + str.slice(1);
}
/**
 * Given two rectangles: [x1, y1, width1, height1] and [x2, y2, width2, height2]
 * Computes an intersection [x_out, y_out, width_out, height_out] of the
 * rectangles if the rectangles intersect and outputs false otherwise.
 *
 * @param {Array<int>} rect1 4-tuple [x1, y1, width1, height1] of first
 *  rectangle
 * @param {Array<int>} rect2 4-tuple [x1, y1, width1, height1] of second
 *  rectangle
 * @return {Array<int>|boolean} 4-tuple [x_out, y_out, width_out, height_out] of
 *  intersection rectangle or false otherwise
 */
function intersectRectangles(rect1, rect2)
{
    [x1, y1, width1, height1] = rect1.map((x) => {return 1*x}); //make int
    [x2, y2, width2, height2] = rect2.map((x) => {return 1*x});
    let x_out = Math.max(x1, x2);
    let y_out = Math.max(y1, y2);
    let width_out = Math.min(x1 + width1, x2 + width2) - x_out;
    let height_out = Math.min(y1 + height1, y2 + height2) - y_out;
    if (isNaN(x_out) || isNaN(y_out) || isNaN(width_out) ||
        isNaN(height_out) || width_out <= 0 || height_out <= 0) {
        return false;
    }
    return [x_out, y_out, width_out, height_out];
}
/**
 * Used to convert a DOM Element dom_object to an Object or Location suitable
 * for a FRISE game. dom_object's whose tagName does not begin with x-
 * will result in null being returned. If the tagName is x-location, a
 * Location object will be returned, otherwise, a Javascript Object is returned.
 * The innerHTML of any subtag of an x-object or an
 * x-location beginning with x- becomes the value of a field in the resulting
 * object with the name of the tag less x-. For example, a DOM Object
 * representing the following HTML code:
 * <x-object id="bob">
 *   <x-name>Robert Smith</x-name>
 *   <x-age>25</x-age>
 * </x-object>
 * will be processed to a Javascript Object
 * {
 *   id: "bob",
 *   name: "Robert Smith",
 *   age: "25"
 * }
 * @param {Element} DOMElement to be convert into a FRISE game Object or
 *  Location
 * @return {Object} the resulting FRISE game Object or Location or
 *  null if the tagName of the DOMElement didn't begin with x-
 */
function makeGameObject(dom_object)
{
    if (!dom_object || dom_object.tagName.substring(0, 2) != "X-") {
        return null;
    }
    let tag_name = dom_object.tagName.slice(2);
    let type = dom_object.getAttribute("type");
    if (type) {
        type = upperFirst(type);
    } else if (tag_name && tag_name != "OBJECT") {
        type = upperFirst(tag_name.toLowerCase());
    } else {
        type = "Object";
    }
    let game_object;
    if (type == "Location") {
        game_object = new Location();
    } else if (type == "Doll") {
        game_object = new Doll();
    } else if (type == "Slots") {
        type = "DollSlots";
        game_object = new DollSlots();
    } else if (type == "Slot") {
        type = "DollSlot";
        game_object = new DollSlot();
    } else {
        game_object = {};
    }
    if (dom_object.id) {
        game_object.id = dom_object.id;
    } else {
        game_object.id = "oid" + object_counter;
        object_counter++;
    }
    let style = dom_object.getAttribute('style');
    if (typeof style != 'undefined') {
        game_object.style = style;
    }
    let class_list = dom_object.getAttribute('class');
    if (typeof class_list != 'undefined') {
        game_object["class"] = class_list;
    }
    let has = {
        'color' : false,
        'icon' : false,
        'height' : false,
        'position' : false,
        'present' : false,
        'slots' : false,
        'width' : false
    };
    let script_tags = {
        "text/action" : "X-ACTION",
        "text/default-action" : "X-DEFAULT-ACTION",
        "text/click-listener" : "X-CLICK-LISTENER",
        "text/collision-listener" : "X-COLLISION-LISTENER",
        "text/update-listener" : "X-UPDATE-LISTENER",
    };
    for (const child of dom_object.children) {
        let tag_name = child.tagName;
        if (tag_name == 'SCRIPT') {
            let script_type = child.getAttribute("type");
            if (typeof script_tags[script_type] != 'undefined') {
                tag_name = script_tags[script_type];
            }
        }
        if (tag_name.substring(0, 2) != "X-") {
            continue;
        }
        let attribute_name = tag_name.slice(2);
        if (attribute_name) {
            attribute_name = attribute_name.toLowerCase();
            has[attribute_name] = true;
            if (attribute_name == 'present') {
                if (!game_object[attribute_name]) {
                    game_object[attribute_name] = [];
                }
                let check = "";
                let is_else = false;
                for(let check_attr of ['ck', 'check', 'else-ck',
                    'else-check', 'else']) {
                    let tmp_check = child.getAttribute(check_attr);
                    if (tmp_check) {
                        check = tmp_check;
                        if (['else-ck', 'else-check'].includes(check_attr)) {
                            is_else = true;
                        }
                        break;
                    }
                }
                let stage = child.getAttribute("stage");
                if (!stage) {
                    stage = "";
                }
                game_object[attribute_name].push([check, stage, is_else,
                    child.innerHTML]);
            } else if (type == 'Doll' && attribute_name == 'slots') {
                game_object[attribute_name] = makeGameObject(child);
            } else if (type == 'DollSlots' && attribute_name == 'slot') {
                let slot = makeGameObject(child);
                if (slot.type == 'DollSlot') {
                    game_object.push(slot);
                }
            } else {
                game_object[attribute_name] = child.innerHTML;
            }
        }
    }
    game_object.type = type;
    if (type == 'Location') {
        game_object.has_present = has['present'];
    } else if (type == 'Object') {
        game_object.has_position = has['position'];
    } else if (type == 'Doll') {
        game_object.has_height = has['height'];
        game_object.has_icon = has['icon'];
        game_object.has_color = has['color'];
        game_object.has_width = has['width'];
        if (!has['slots']) {
            game_object.slots = new DollSlots();
        }
        game_object.init();
    }
    return game_object;
}
/**
 * Used to change the display css property of the element of the provided id
 * to display_type if it doesn't already have that value, if it does,
 * then the display property is set to none. If display_type is not provided
 * then its value is assumed to be block.
 *
 * @param {string} id of HTML Element to toggle display property of
 * @param {string} display_type value to toggle between it and none.
 */
function toggleDisplay(id, display_type = "block")
{
    let obj = elt(id);
    if (obj.style.display == display_type) {
        value = "none";
    } else {
        value = display_type;
    }
    obj.style.display = value;
    if (value == "none") {
        obj.setAttribute('aria-hidden', true);
    } else {
        obj.setAttribute('aria-hidden', false);
    }
}
/**
 * Used to toggle the display or non-display of the main navigation bar
 * on the side of game screen
 */
function toggleMainNav()
{
    let game_content = elt('game-content');
    let nav_obj = elt('main-nav');
    if ((!nav_obj.style.left && !nav_obj.style.right) ||
        nav_obj.style.left == '0px' || nav_obj.style.right == '0px') {
        game_content.style.width = "calc(100% - 40px)";
        if (is_right_to_left) {
            game_content.style.right = "55px";
            nav_obj.style.right = '-300px';
        } else {
            game_content.style.left = "55px";
            nav_obj.style.left = '-300px';
        }
        if (is_mobile) {
            nav_obj.style.width = "240px";
            game_content.style.width = "calc(100% - 70px)";
        }
        nav_obj.style.backgroundColor = 'white';
    } else {
        if (is_right_to_left) {
            nav_obj.style.right = '0px';
            game_content.style.right = "300px";
        } else {
            nav_obj.style.left = '0px';
            game_content.style.left = "300px";
        }
        game_content.style.width = "calc(100% - 480px)";
        if (is_mobile) {
            nav_obj.style.width = "100%";
            if (is_right_to_left) {
                game_content.style.right = "100%";
            } else {
                game_content.style.left = "100%";
            }
        }
        nav_obj.style.backgroundColor = 'lightgray';
    }
}
/**
 * Adds click event listeners to all anchor objects in a list
 * that have href targets beginning with #. Such a target
 * is to a location within the current game, so the click event callback
 * then calls game.takeTurn based on this url.
 *
 * @param {NodeList|HTMLCollection} anchors to add click listeners for
 *  game take turn callbacks.
 */
function addListenersAnchors(anchors)
{
    let call_toggle = false;
    if (arguments.length > 1) {
        if (arguments[1]) {
            call_toggle = true;
        }
    }
    for (const anchor of anchors) {
        let url = anchor.getAttribute("href");
        if (url && url[0] == "#") {
            let hash = url;
            anchor.innerHTML = "<span tabindex='0'>" +anchor.innerHTML +
                "</span>";
            let handle =  async (event) => {
                let transition = anchor.getAttribute('data-transition');
                let duration = anchor.getAttribute('data-duration');
                let iterations = anchor.getAttribute('data-iterations');
                let origin = anchor.getAttribute('data-transform-origin');
                let preset_origins = { 'top-left-origin' : "0 0",
                    'top-mid-origin' : "50% 0",
                    'top-right-origin' : "100% 0",
                    'bottom-left-origin' : "0 50%",
                    'bottom-mid-origin' : "50% 100%",
                    'bottom-right-origin' : "100% 100%",
                    'mid-left-origin' : "0 50%",
                    'mid-right-origin' : "100% 50%",
                    'mid-mid-origin' : "50% 50%",
                    'center-origin' : "50% 50%"
                };
                if (transition) {
                    let game_content = elt('game-content');
                    game_content.classList.add(transition);
                    duration = (duration) ? parseInt(duration) : 2000;
                    iterations = (iterations) ? parseInt(iterations) : 1;
                    origin ??= "50% 50%";
                    if (typeof preset_origins[origin] != 'undefined') {
                        origin = preset_origins[origin];
                    }
                    game_content.style.animationDuration = duration + "ms";
                    game_content.style.animationIterationCount = iterations;
                    game_content.style.transformOrigin = origin;
                    await sleep(duration);
                    elt('game-content').classList.remove(transition);
                }
                let is_slides = sel("x-slides")[0] ? true : false;
                if (is_slides) {
                    window.location = hash;
                    game.takeTurn(hash);
                    return;
                }
                if (!anchor.classList.contains('disabled')) {
                    game.takeTurn(hash);
                    if (game.has_nav_bar && call_toggle) {
                        toggleMainNav();
                    }
                }
                event.preventDefault();
                if (window.location.hash) {
                    delete window.location.hash;
                }
            };
            anchor.addEventListener('keydown', (event) => {
                if (event.code == 'Enter' || event.code == 'Space') {
                    handle(event);
                }
            });
            anchor.addEventListener('click', (event) => handle(event));
        } else if (!url || url.length < 11 ||
            url.substring(0, 11).toLowerCase() != 'javascript:') {
            anchor.innerHTML = "<span tabindex='0'>" + anchor.innerHTML +
                "</span>";
            anchor.addEventListener('click', (event) => {
                window.location = url;
            });
        }
    }
}
/**
 * Used to disable any volatile links which might cause
 * issues if clicked during rendering of the staging
 * portion of a presentation. For example, saves screen links
 * in either the main-nav panel/main-nav bar or in the
 * game content area. The current place where this is used
 * is at the start of rendering a location. A location
 * might have several x-present tags, some of which could
 * be drawn after a clickProceed, or a delay. As these
 * steps in rendering are not good "save places", at the
 * start of rendering a location, all save are disabled.
 * Only after the last drawable x-present for a location
 * has completed are they renabled.
 */
function disableVolatileLinks()
{
    for (let location_id of game.volatileLinks()) {
        let volatile_links = sel(`[href~="#${location_id}"]`);
        for (const volatile_link of volatile_links) {
            volatile_link.classList.add('disabled');
        }
    }
}
/**
 * Used to re-enable any disabled volatile links
 * in either the main-nav panel/main-nav bar or in the
 * game content area. The current place where this is used
 * is at the end of rendering a location. For why this is called,
 * @see disableVolatileLinks()
 */
function enableVolatileLinks()
{
    for (let location_id of game.volatileLinks()) {
        let volatile_links = sel(`[href~="#${location_id}"]`);
        for (const volatile_link of volatile_links) {
            volatile_link.classList.remove('disabled');
        }
    }
}
/**
 * Given a string which may contain Javascript string interpolation expressions,
 * i.e., ${some_expression}, produces a string where those expressions have
 * been replaced with their values.
 *
 * @param {string} text to have Javascript interpolation expressions replaced
 *  with their values
 * @return {string} result of carrying out the replacement
 */
function interpolateVariables(text)
{
    old_text = "";
    while (old_text != text) {
        old_text = text;
        let interpolate_matches = text.match(/\$\{([^\}]+)\}/);
        if (interpolate_matches && interpolate_matches[1]) {
            let interpolate_var = interpolate_matches[1];
            if (interpolate_var.replace(/\s+/, "") != "") {
                interpolate_var = interpolate_var.replaceAll(/\&gt\;?/g, ">");
                interpolate_var = interpolate_var.replaceAll(/\&lt\;?/g, "<");
                interpolate_var = interpolate_var.replaceAll(/\&amp\;?/g, "&");
                if (interpolate_var) {
                    let interpolate_value = eval(interpolate_var);
                    text = text.replace(/\$\{([^\}]+)\}/, interpolate_value);
                }
            }
        }
    }
    return text;
}
/**
 * A data-ck attribute on any tag other than an x-present tag can
 * contain a Javascript boolean expression to control the display
 * or non-display of that element. This is similar to a ck attribute
 * of an x-present tag. This method evalutes data-ck expressions for
 * each tag in the text in its section argument and adds a class="none"
 * attribute to that tag if it evaluates to false (causing it not to
 * display). The string after these substitutions is returned.
 *
 * @param {string} section of text to check for data-ck attributes and
 *  for which to carry out the above described substitutions
 * @return {string} after substitutions have been carried out.
 */
function evaluateDataChecks(section)
{
    let quote = `(?:(?:'([^']*)')|(?:"([^"]*)"))`;
    let old_section = "";
    while (section != old_section) {
        old_section = section;
        let data_ck_pattern = new RegExp(
            "\<(?:[^\>]+)((?:data\-)?ck\s*\=\s*(" + quote + "))(?:[^\>]*)\>",
        'i');
        section = section.replace(/\&amp;/g, "&");
        let data_ck_match = section.match(data_ck_pattern);
        if (!data_ck_match || section.includes("cli" + data_ck_match[1])) {
            //don't want above to accidentally also match onclick=
            continue;
        }
        let condition = (typeof data_ck_match[3] == 'string') ?
             data_ck_match[3] : data_ck_match[4];
        if (typeof condition == 'string') {
            condition = condition.replace(/\&lt;/g, "<");
            condition = condition.replace(/\&gt;/g, ">");
            let check_result = (condition.replace(/\s+/, "") != "") ?
                eval(condition) : true;
            if (check_result) {
                section = section.replace(data_ck_match[1], " ");
            } else {
                section = section.replace(data_ck_match[1],
                    " class='none' ");
            }
        }
    }
    return section;
}
/**
 * Returns the game object with the provided id.
 * @param {string} object_id to get game object for
 * @return {object} game object associated with object_id if it exists
 */
function obj(object_id)
{
    return game.objects[object_id];
}
/**
* Returns the game location with the provided id.
* @param {string} object_id to get game Location for
* @return {Location} game location associated with object_id if it exists
 */
function loc(location_id)
{
    return game.locations[location_id];
}
/**
* Returns the game doll (paperdoll) with the provided id.
* @param {string} object_id to get game doll for
* @return {Location} game doll associated with object_id if it exists
 */
function doll(doll_id)
{
    return game.dolls[doll_id];
}
/**
 * Returns the game object associated with the main-character. This
 * function is just an abbreviation for obj('main-character')
 * @return {object} game object associated with main-character
 */
function mc()
{
    return game.objects['main-character'];
}
/**
 * Returns the location object associated with the main-character current
 * position.
 * @return {Location} associated with main-character position
 */
function here()
{
    return game.locations[game.objects['main-character'].position];
}
/**
 * Returns the Location object the player begins the game at
 */
function baseLoc()
{
    return game.locations[game.base_location];
}
/**
 * For use in default actions only!!! Returns whether the main-character is
 * in the Location of the default action. In all other cases returns false
 * @return {boolean} where the main-character is in the room of the current
 *  default action
 */
function isHere()
{
    return game['is_here'];
}
/**
 * Returns whether the main character has ever been to the location
 * given by location_id
 *
 * @param {string} location_id id of location checking if main character has
 *  been to
 * @return {boolean} whther the main chracter has been there
 */
function hasVisited(location_id)
{
    return (loc(location_id).visited > 0);
}
/**
 * Encapsulates one place that objects can be in a Game.
 */
class Location
{
    /**
     * An array of [check_condition, staging, is_else, text_to_present] tuples
     * typically coming from the x-present-tag's in the HTML of a Location.
     * @type {Array}
     */
    present = [];
    /**
     * Number of times main-character has visited a location
     * @type {int}
     */
    visited = 0;
    /**
     * Used to display a description of a location to the game content
     * area. This description is based on the x-present tags that were
     * in the x-location tag from which the Location was parse. For
     * each such x-present tag in the order it was in the original HTML,
     * the ck/else-ck condition and staging is first evaluated
     * once/if the condition is satisfied, staging is processed (this may
     * contain a delay or a clickProceed call), then the HTML contents of the
     * tag are shown. In the case where the ck or else-ck evaluates to false
     * than the x-present tag's contents are omitted. In addition to the
     * usual HTML tags, an x-present tag can have x-speaker subtags. These
     * allow one to present text from a speaker in bubbles. An x-present tag
     * may also involve input tags to receive/update values for x-objects or
     * x-locations.
     */
    async renderPresentation()
    {
        disableVolatileLinks();
        let game_content = elt("game-content");
        game_content.innerHTML = "";
        if (typeof this["style"] != 'undefined' && this["style"]) {
            game_content.setAttribute('style', this["style"]);
        }
        if (typeof this["class"] != 'undefined' && this["class"]) {
            game_content.setAttribute('class', this["class"]);
        }
        game_content.scrollTop = 0;
        game_content.scrollLeft = 0;
        let check, staging, is_else, section_html;
        let check_result, proceed, pause;
        check_result = false;
        for (let section of this.present) {
            if (!section[3]) {
                continue;
            }
            [check, staging, is_else, section_html] = section;
            if (is_else && check_result) {
                continue;
            }
            [check_result, proceed, pause] =
                this.evaluateCheckConditionStaging(check, staging);
            if (check_result) {
                let prepared_section = this.prepareSection(section_html);
                if (proceed) {
                    let old_inner_html = game_content.innerHTML;
                    event = await clickProceed(proceed);
                    event.preventDefault();
                    game_content.innerHTML = old_inner_html;
                } else if (pause) {
                    await sleep(pause);
                }
                /*
                   A given prepared section might involve
                   <x-stage></x-stage> tags to simulate a typewriter effect,
                   or delays or clicks to continue.
                   The code below is used to present these tags.
                 */
                let rest_section = prepared_section;
                let final_section = "";
                let xstage_open = "<x-stage";
                let xstage_close = "</x-stage";
                let open_pos, end_pos, greater_pos;
                end_pos = rest_section.lastIndexOf(xstage_close);
                if (end_pos > 0) {
                    final_section = rest_section.substring(end_pos +
                        xstage_close.length);
                    greater_pos = final_section.search(">");
                    if (greater_pos >= 0) {
                        final_section = final_section.substring(greater_pos + 1);
                    } else {
                        greater_pos = final_section.length;
                    }
                    rest_section = rest_section.substring(0, end_pos +
                        xstage_close.length + greater_pos);
                }
                let inner_html = game_content.innerHTML;
                while ((open_pos = rest_section.search(xstage_open)) != -1) {
                    let animation_speed = 50;
                    let click_matches = false;
                    let is_short_tag = false;
                    inner_html += rest_section.substring(0, open_pos);
                    rest_section = rest_section.substring(open_pos +
                        xstage_open.length);
                    greater_pos = rest_section.search(">");
                    if (rest_section.charAt(greater_pos - 1) == "/") {
                        is_short_tag = true;
                    }
                    if (greater_pos >= 0) {
                        let tag_contents = rest_section.substring(0,
                            greater_pos + 1);
                        let delay_matches = tag_contents.match(
                            /delay\=(\"(\d+)\"|\'(\d+)\')/);
                        if (delay_matches) {
                             animation_speed =
                                 (typeof delay_matches[2] == 'undefined') ?
                                 delay_matches[3] : delay_matches[2];
                             animation_speed = parseInt(animation_speed);
                        }
                        click_matches = tag_contents.match(
                            /delay\=(\"click\"|\'click\')/);
                        rest_section = rest_section.substring(greater_pos + 1);
                    } else {
                        rest_section = "";
                    }
                    end_pos = rest_section.search(xstage_close);
                    open_pos = rest_section.search(xstage_open);
                    let stage_text = "";
                    if (is_short_tag || end_pos == -1 || (open_pos > 0 &&
                        end_pos > open_pos)) {
                        end_pos = 0;
                        // assume tag was not for delayed typing, just for delay
                    } else {
                        stage_text = rest_section.substring(0, end_pos);
                        rest_section = rest_section.substring(end_pos +
                            xstage_close.length);
                        greater_pos = rest_section.search(">");
                        if (greater_pos >= 0) {
                            rest_section =
                                rest_section.substring(greater_pos + 1);
                        }
                    }
                    if (click_matches) {
                        game_content.innerHTML = inner_html;
                        event = await clickProceed(stage_text);
                        event.preventDefault();
                        event.stopPropagation();
                        game_content.innerHTML = inner_html;
                    } else {
                        let old_length = inner_html.length;
                        inner_html += stage_text;
                        game.stop_stage = false;
                        let game_content = elt('game-content');
                        game_content.setAttribute('tabindex', 0);
                        game_content.addEventListener('click', () => {
                            game.stop_stage = true;
                        });
                        game_content.addEventListener('keydown', () => {
                            game.stop_stage = true;
                        });
                        await sleep(animation_speed);
                        for (let i = old_length; i <= old_length + end_pos;
                            i++) {
                            game_content.innerHTML = inner_html.substring(0, i)
                            await sleep(animation_speed);
                            if (game.stop_stage) {
                                game_content.onclick = null;
                                break;
                            }
                        }
                    }
                }
                game_content.innerHTML = inner_html + rest_section +
                    final_section;
            }
        }
        // footer used so a game location page doesn't get squished
        if (!sel("x-slides")[0]) {
            game_content.innerHTML += "<div class='footer-space'></div>";
        }
        this.prepareControls();
        if (this['screen-footer']) {
            let footer = elt('screen-footer');
            if (!footer) {
                footer = document.createElement('div');
                footer.id = 'screen-footer';
                elt('game-screen').appendChild(footer);
            }
            footer.innerHTML = this['screen-footer'];
        }
        let anchors = sel("#game-screen a, #game-screen x-button");
        addListenersAnchors(anchors);
        this.renderDollHouses();
        if (typeof sessionStorage['show_footer'] != 'undefined') {
            if (!sessionStorage['show_footer'] ||
                sessionStorage['show_footer'] == 'false') {
                let slide_footer =
                    sel('#game-screen .slide-footer')[0];
                slide_footer.style.display = 'none';
            }
        }
        if (!game.volatileLinks().includes(mc().position)) {
            enableVolatileLinks();
        }
    }
    /**
     * For each Dollhouse in the current main-nav and game-content
     * areas of the HTML browser window draw the Dollhouses contents to a
     * canvas tag  associated with it. Then sets up any key, click, and
     * collision  listeners associated with the Dollhouse to handle events
     * on that canvas.
     */
    renderDollHouses()
    {
        for (const screen_id of ["main-nav", "game-content"]) {
            let screen_part = elt(screen_id);
            if (!screen_part) {
                continue;
            }
            let part_width = screen_part.clientWidth;
            let part_height = screen_part.clientHeight;
            let dom_dollhouses = sel(`#${screen_id} x-dollhouse`);
            for (const dom_dollhouse of dom_dollhouses) {
                let dollhouse = makeGameObject(dom_dollhouse);
                if (!dollhouse['doll-id']) {
                    continue;
                }
                let doll_obj = doll(dollhouse['doll-id']);
                if (!doll_obj) {
                    continue;
                }
                let width = dollhouse.width ?? part_width;
                let height = dollhouse.height ?? part_height;
                let source_x = dollhouse['source-x'] ?? (doll_obj['source-x'] ??
                    0);
                dollhouse['source-x'] = source_x;
                let source_y = dollhouse['source-y'] ?? (doll_obj['source-y'] ??
                    0);
                dollhouse['source-y'] = source_y;
                let source_width = dollhouse['source-width'] ??
                    (doll_obj['source-width'] ?? doll_obj.width);
                dollhouse['source-width'] = source_width;
                let source_height = dollhouse['source-height'] ??
                    (doll_obj['source-height'] ?? doll_obj.height);
                dollhouse['source-height'] = source_height;
                let canvas = document.createElement('canvas');
                let dom_class = dom_dollhouse.getAttribute('class');
                dom_class = (dom_class) ? `block ${dom_class}` : 'block';
                let dom_style = dom_dollhouse.getAttribute('style');
                dom_style ??= '';
                canvas.setAttribute('class', dom_class);
                canvas.setAttribute('style', dom_style);
                canvas.setAttribute('width', width);
                canvas.setAttribute('height', height);
                canvas.setAttribute('data-source-x', source_x);
                canvas.setAttribute('data-source-y', source_y);
                canvas.setAttribute('data-source-width', source_width);
                canvas.setAttribute('data-source-height', source_height);
                let is_navigable = (typeof dollhouse.navigable != 'undefined' &&
                    (dollhouse.navigable === true ||
                    dollhouse.navigable === "true"));
                if (is_navigable) {
                    let player_slot = doll_obj.getPlayerSlot(dollhouse);
                    player_slot.init(doll_obj);
                    dollhouse.player_slot = player_slot;
                }
                dollhouse.doll = doll_obj;
                dollhouse.canvas = canvas;
                dom_dollhouse.parentNode.insertBefore(canvas,
                    dom_dollhouse.nextSibling);
                doll_obj.render(canvas);
                if (is_navigable ||
                    doll_obj.has_collision_listeners ||
                    doll_obj.has_click_listeners ||
                    doll_obj.has_update_listeners) {
                    this.addDollHouseListeners(dollhouse);
                }
            }
        }
    }
    /**
     * Sets up the canvas element listeners associated with a Dollhouse.
     * Possible
     *
     * @param {Dollhouse} whose canvas will add listeners to
     */
    addDollHouseListeners(dollhouse)
    {
        let canvas = dollhouse.canvas;
        let doll_obj = dollhouse.doll;
        let player_slot = doll_obj.getPlayerSlot(dollhouse);
        dollhouse.player_slot = player_slot;
        if (dollhouse.initialized) {
            return;
        }
        if ((dollhouse.navigable === true ||
            dollhouse.navigable === "true")) {
            parseObjectsInts([ [player_slot, ["x", "y", "width", "height"]],
                [doll_obj, ["width", "height"]], [dollhouse, ["source-x",
                "source-y", "source-width", "source-height",
                'padding-top', 'padding-left', 'padding-bottom',
                'padding-right']] ]);
            dollhouse['source-width'] = (dollhouse['source-width'] > 0) ?
                dollhouse['source-width'] : doll_obj.width;
            dollhouse['source-height'] = (dollhouse['source-height'] > 0) ?
                dollhouse['source-height'] : doll_obj.height;
            let player_half_width = Math.floor(player_slot.width/2);
            let player_half_height = Math.floor(player_slot.height/2);
            let step_x = dollhouse['player-step-x'] ??
                (dollhouse['player-step'] ?? 1);
            step_x = parseInt(step_x);
            let step_y = dollhouse['player-step-y'] ??
                (dollhouse['player-step'] ?? 1);
            step_y = parseInt(step_y);
            let scroll_step_x = 0;
            let scroll_step_y = 0;
            let x_min = dollhouse['source-x'] + player_half_width;
            let y_min = dollhouse['source-y'] + player_half_height;
            let x_max = dollhouse['source-x'] + dollhouse['source-width'] -
                player_half_width;
            let y_max = dollhouse['source-y'] + dollhouse['source-height'] -
                player_half_height;
            if (dollhouse['scrollable'] && dollhouse['scrollable'] == "true") {
                scroll_step_x = step_x;
                scroll_step_y = step_y;
                x_min = player_half_width;
                x_max = doll_obj.width - player_half_width;
                y_min = player_half_height;
                y_max = doll_obj.height - player_half_height;
            }
            x_min += dollhouse['padding-left'];
            x_max -= dollhouse['padding-right'];
            y_min += dollhouse['padding-top'];
            y_max -= dollhouse['padding-bottom'];
            canvas.setAttribute('tabindex', 0);
            canvas.focus();
            canvas.addEventListener('keydown', event => {
                if (player_slot.old_x &&
                    dollhouse.has_collision_listeners) {
                    return;
                }
                let change = false;
                let old_values = [player_slot.x, player_slot.y,
                    dollhouse['source-x'], dollhouse['source-y'],
                    player_slot.width, player_slot.height
                ];
                let x_pos = player_slot.x + player_half_width;
                let y_pos = player_slot.y + player_half_height;
                switch(event.code) {
                    case 'KeyW':
                    case 'ArrowUp':
                        if (y_pos > y_min) {
                            change = true;
                            dollhouse['source-y'] -= scroll_step_y;
                            player_slot.y -= step_y;
                        }
                        break;
                    case 'KeyS':
                    case 'ArrowDown':
                        if (y_pos < y_max) {
                            change = true;
                            dollhouse['source-y'] += scroll_step_y;
                            player_slot.y += step_y;
                        }
                        break;
                    case 'KeyA':
                    case 'ArrowLeft':
                        if (x_pos > x_min) {
                            change = true;
                            dollhouse['source-x'] -= scroll_step_x;
                            player_slot.x -= step_x;
                        }
                        break;
                    case 'KeyD':
                    case 'ArrowRight':
                        if (x_pos < x_max) {
                            change = true;
                            dollhouse['source-x'] += scroll_step_x;
                            player_slot.x += step_x;
                        }
                        break;
                }
                if (change) {
                    [player_slot.old_x, player_slot.old_y,
                    player_slot.old_source_x, player_slot.old_source_y,
                    player_slot.old_width, player_slot.old_height] =
                        old_values;
                }
            });
        }
        if (doll_obj.has_click_listeners) {
            canvas.addEventListener("click", (event) => {
                if (doll_obj['click-listener']) {
                    eval(doll_obj['click-listener']);
                }
                if (doll_obj.slots.has_click_listeners) {
                    let rect = canvas.getBoundingClientRect();
                    let x = event.clientX - rect.left;
                    let y = event.clientY - rect.top;
                    let source_x = dollhouse['source-x'] ?? 0;
                    let source_y = dollhouse['source-y'] ?? 0;
                    let source_width = dollhouse['source-width'] ??
                        doll_obj.width;
                    let source_height = dollhouse['source-height'] ??
                        doll_obj.height;
                    x = Math.floor((source_width * x / canvas.width) + source_x);
                    y = Math.floor((source_height * y / canvas.height) +
                        source_y);
                    for (const slot of doll_obj.slots.items) {
                        if (typeof slot != 'undefined' &&
                            slot['click-listener']) {
                            parseObjectsInts([[slot,
                                ["x", "y", "width", "height"]]]);
                            if (slot.x <= x && x <= slot.x + slot.width &&
                                slot.y <= y && y <= slot.y + slot.height) {
                                eval(slot['click-listener']);
                                doll_obj.redraw = true;
                            }
                        }
                    }
                }
            });
        }
        if (dollhouse['navigable'] || doll_obj.has_update_listeners ||
            doll_obj.has_collision_listeners ||
            doll_obj.has_click_listeners) {
            let refresh = (dollhouse.refresh) ? parseInt(dollhouse.refresh):
                game.dollhouse_refresh_rate;
            game.dollhouse_update_ids[game.dollhouse_update_ids.length] =
                setInterval((event) => {
                    if (doll_obj['update-listener']) {
                        eval(doll_obj['update-listener']);
                        doll_obj.redraw = true;
                    }
                    for (const slot of doll_obj.slots.items) {
                        if (typeof slot != 'undefined' &&
                            slot['update-listener']) {
                            eval(slot['update-listener']);
                            doll_obj.redraw = true;
                        }
                    }
                    if (player_slot && player_slot.old_x) {
                        doll_obj.redraw = true;
                    }
                    if (doll_obj.has_collision_listeners) {
                        doll_obj.checkCollisions(dollhouse);
                    }
                    if (player_slot && player_slot.old_x) {
                        delete player_slot.old_x;
                        delete player_slot.old_y;
                        delete player_slot.old_source_x;
                        delete player_slot.old_source_y;
                        delete player_slot.old_width;
                        delete player_slot.old_height;
                    }
                    if (doll_obj.redraw) {
                        let source_x = dollhouse['source-x'] ?? 0;
                        canvas.setAttribute('data-source-x', source_x);
                        let source_y = dollhouse['source-y'] ?? 0;
                        canvas.setAttribute('data-source-y', source_y);
                        doll_obj.render(canvas);
                        doll_obj.redraw = false;
                    }
                }, refresh);
        }
        dollhouse.initialized = true;
    }
    /**
     * Prepares input, textareas, and select tags in the game so that they can
     * bind to game Object or Location fields by adding various Javascript
     * Event handlers. An input tag like
     * <input data-for="bob" name="name" >
     * binds with the name field of the bob game object (i.e., obj(bob).name).
     * In the case above, as the default type of an input tag is text, this
     * would produce a text field whose initial value is the current value
     * obj(bob).name. If the user changes the field, the value of the obj
     * changes with it. This set up binds input tags regardless of type, so it
     * can be used with other types such as range, email, color, etc.
     */
    prepareControls()
    {
        const content_areas = ["main-nav", "game-content"];
        for (const content_area of content_areas) {
            let content = elt(content_area);
            if (!content) {
                continue;
            }
            if (content_area == 'main-nav') {
                if (!content.hasOwnProperty("originalHTML")) {
                    content.originalHTML = content.innerHTML;
                }
                content.innerHTML = interpolateVariables(content.originalHTML);
                content.innerHTML = evaluateDataChecks(content.innerHTML);
                game.initializeGameNavListeners();
            }
            let control_types = ["input", "textarea", "select"];
            for (const control_type of control_types) {
                let control_fields = content.querySelectorAll(control_type);
                for (const control_field of control_fields) {
                    let target_object = null;
                    let target_name = control_field.getAttribute("data-for");
                    let control_callback = control_field.getAttribute(
                        "data-handler");
                    if (typeof target_name != "undefined") {
                        if (game.objects[target_name]) {
                            target_object = game.objects[target_name];
                        } else if (game.locations[target_name]) {
                            target_object = game.locations[target_name];
                        }
                        if (target_object) {
                            let target_field = control_field.getAttribute(
                                "name");
                            let control_subtype = '';
                            if (target_field) {
                                if (control_type == "input") {
                                    control_subtype =
                                        control_field.getAttribute("type");
                                    if (control_subtype == 'radio' ||
                                        control_subtype == 'checkbox') {
                                        if (control_field.value ==
                                            target_object[target_field]) {
                                            control_field.checked =
                                                target_object[target_field];
                                        }
                                    } else {
                                        control_field.value =
                                            target_object[target_field];
                                    }
                                } else if (target_object[target_field]) {
                                    /* if don't check
                                       target_object[target_field] not empty
                                       then select tags get extra blank option
                                     */
                                    control_field.value =
                                        target_object[target_field];
                                }
                                if(!control_field.disabled) {
                                    if (control_type == "select") {
                                        control_field.addEventListener("change",
                                        (evt) => {
                                            target_object[target_field] =
                                                control_field.value;
                                            if (control_callback) {
                                                window[control_callback](evt);
                                            }
                                        });
                                    } else if (control_subtype == "radio") {
                                        control_field.addEventListener("click",
                                        (evt) => {
                                            if (control_field.checked) {
                                                target_object[target_field] =
                                                    control_field.value;
                                            }
                                            if (control_callback) {
                                                window[control_callback](evt);
                                            }
                                        });
                                    } else if (control_subtype == "checkbox") {
                                        control_field.addEventListener("change",
                                        (evt) => {
                                            if (control_field.checked) {
                                                target_object[target_field] =
                                                    control_field.value;
                                            } else {
                                                target_object[target_field] =
                                                    "";
                                            }
                                            if (control_callback) {
                                                window[control_callback](evt);
                                            }
                                        });
                                    } else {
                                        control_field.addEventListener("input",
                                        (evt) => {
                                            target_object[target_field] =
                                                control_field.value;
                                            if (control_callback) {
                                                window[control_callback](evt);
                                            }
                                        });
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    }
    /**
     * Evaluates the condition in a ck or else-ck attribute of an x-present tag.
     *
     * @param {string} condition contents from a ck, check, else-ck,
     *      or else-check attribute.  Conditions can be boolean conditions
     *      on game variables. If an x-present tag did not have a ck attribute,
     *      condition is null.
     * @param {string} staging contents from a stage attribute.
     *      If no such attribute, this will be an empty string.
     *      Such an attribute could have a sequence of
     *      pause(some_millisecond); and clickProceed(some_string) commands
     * @return {Array} [check_result, proceed, pause, typing] if the condition
     *  involved   a boolean expression, then check_result will hold the result
     *  of   the expression (so the caller then could prevent the the display of
     *  an x-present tag if false), proceed is the link text (if any) for a
     *  link for the first clickProceed (which is supposed to delay the
     *  presentation of the x-present tag until after the user clicks the
     *  link) is found (else ""), pause (if non zero) is the number of
     *  milliseconds to sleep before presenting the x-present tag according to
     *  the condition,
     */
    evaluateCheckConditionStaging(condition, staging)
    {
        let proceed = "";
        let pause = 0;
        condition = (typeof condition == "string") ? condition : "";
        let check_result = (condition.replace(/\s+/, "") != "") ?
            eval(condition) : true;
        if (typeof check_result != "boolean") {
            check_result = false;
            console.log(condition + " didn't evaluate to a boolean");
        }
        let staging_remainder = staging;
        let old_staging = "";
        while (check_result && old_staging != staging_remainder) {
            old_staging = staging_remainder;
            let click_pattern = /clickProceed\([\'\"]([^)]+)[\'\"]\);?/;
            let click_match = staging_remainder.match(click_pattern);
            if (click_match) {
                proceed = click_match[1];
                break;
            }
            let pause_pattern = /pause\(([^)]+)\);?/;
            let pause_match = staging_remainder.match(pause_pattern);
            if (pause_match) {
                pause += parseInt(pause_match[1]);
            }
        }
        return [check_result, proceed, pause];
    }
    /**
     * A given Location contains one or more x-present tags which are
     * used when rendering that location to the game-content area. This
     * method takes the text from one such tag, interpolates any game
     * variables into it, and adds to any x-speaker tags in it the HTML
     * code to render that speaker bubble (adds img tag for speaker icon, etc).
     *
     * @param {string} section text to process before putting into the
     *  game content area.
     * @return {string} resulting HTML text after interpolation and processing
     */
    prepareSection(section)
    {
        let old_section = "";
        let quote = `(?:(?:'([^']*)')|(?:"([^"]*)"))`;
        section = evaluateDataChecks(section);
        section = interpolateVariables(section);
        while (section != old_section) {
            old_section = section;
            let speaker_pattern = new RegExp(
                "\<x-speaker([^\>]*)name\s*\=\s*(" + quote + ")([^\>]*)>",
                'i');
            let expression_pattern = new RegExp(
                "expression\s*\=\s*("+quote+")", 'i');
            let speaker_match = section.match(speaker_pattern);
            if (speaker_match) {
                let speaker_id = speaker_match[4];
                let pre_name = (speaker_match[3]) ? speaker_match[3] : "";
                let post_name = (speaker_match[5]) ? speaker_match[5] : "";
                let rest_of_tag = pre_name + " " + post_name;
                let expression_match = rest_of_tag.match(expression_pattern);
                if (speaker_id && game.objects[speaker_id]) {
                    let speaker = game.objects[speaker_id];
                    let name = speaker.name;
                    if (expression_match && expression_match[3]) {
                        name += " <b>(" + expression_match[3] + ")</b>";
                    }
                    let icon = speaker.icon;
                    let html_fragment = speaker_match[0];
                    let html_fragment_mod = html_fragment.replace(/name\s*\=/,
                        "named=");
                    if (icon) {
                        section =
                            section.replace(html_fragment, html_fragment_mod +
                            "<figure><img src='" + speaker.icon + "' " +
                            "loading='lazy' ></figure><div>" + name +
                            "</div><hr>");
                    }
                }
            }
        }
        return section;
    }
}
/**
 * Encapsulates one paperdoll that might be drawn or animated to
 * a canvas in the Game. A Doll has a width and height and either
 * an image or background color. It also has a number of DollSlot
 * subrectangles, on which other images or solid colors may be drawn.
 * DollSlots can be animated if they responds to update events, can collide
 * if respond to collision events, or can be clicked on if they respond to
 * click events. A Doll can be navigable if it has a player dollslot
 * and scrollable if it is vaigable and its dimension are large than its
 * viewable area.
 */
class Doll
{
    /**
     * Whether the current Doll or any of its DollSlot objects respond to
     * click events
     * @type {boolean}
     */
    has_click_listeners = false;
    /**
     * Whether the current Doll or any of its DollSlot objects have an update
     * listener specified to be called according to the update time function
     * @type {boolean}
     */
    has_update_listeners = false;
    /**
     * Whether the current Doll or any of its DollSlot objects respond to
     * collision between DollSlot events
     * @type {boolean}
     */
    has_collision_listeners = false;
    /**
     * Number of images in DollSlot object on this Doll that
     * have yet to load
     * @type {int}
     */
    num_loading;
    /**
     * If the Doll is navigable, then this holds the index of the PlayerSlot
     * in the array this.slots.items of DollSlot objects
     * @type {int|boolean}
     */
    player_index = false;
    /**
     * An array of canvas objects on which this Doll has been requested to
     * render, but can't yet (say because relies on images which are still
     * loading)
     * @type {Array<Canvas>}
     */
    render_requests;
    /**
     * Is true if the Doll is currently being drawn in some other thread
     * @type {boolean}
     */
    rendering;
    /**
     * Checks for any collisions between DollSlot objects on the current Doll
     * that have collision listeners specified. If there are then the
     * listeners are called with an event containing info about the two
     * DollSlots involved and the Dollhouse on which the Doll will be rendered.
     *
     * @param {Dollhouse} dollhouse on which the current Doll is to be rendered
     */
    checkCollisions(dollhouse)
    {
        if (this['collision-listener']) {
            eval(this['collision-listener']);
        }
        let items = this.slots.items;
        let len = items.length;
        if (len == 1) {
            return;
        }
        for (let i = 0; i < len; i++) {
            for (let j = i + 1; j < len; j++) {
                let s1 = items[i];
                let s2 = items[j];
                if (typeof s1 == 'undefined' ||
                    typeof s2 == 'undefined' ||
                    (!s1['collision-listener'] && !s2['collision-listener'])) {
                    continue;
                }
                let rect = intersectRectangles(
                    [s1.x, s1.y, s1.width, s1.height],
                    [s2.x, s2.y, s2.width, s2.height]);
                if (rect) {
                    var event = {
                        'a' : s1,
                        'b' : s2,
                        'dollhouse' : dollhouse
                    };
                    if (s1['collision-listener']) {
                        eval(s1['collision-listener']);
                    }
                    if (s2['collision-listener']) {
                        eval(s2['collision-listener']);
                    }
                }
            }
        }
    }
    /**
     * Returns the DollSlot used to manage the player's coordinates and
     * icon image within the current Doll (provided the Doll is navigable).
     * If the player DollSlot doesn't exist an attempt is made to create it.
     * If a Dollhouse is provided this is used in the attempt to create the
     * player slot.
     *
     * @param {DollHouse?} dollhouse that may be used to help create the player
     *  DollSlot
     * @return {DollSlot} the player DollSlot
     */
    getPlayerSlot(dollhouse = null)
    {
        let player_slot;
        let original_dh = dollhouse;
        dollhouse ??= {'player-width': 50, 'player-height': 50,
            'player-x': -1, 'player-x' : -1};
        if (this.player_index === false) {
            player_slot = new DollSlot();
            player_slot.type = 'DollSlot';
            this.setPlayerSlot(player_slot);
        }
        player_slot = this.slots.items[this.player_index];
        if (dollhouse) {
            parseObjectsInts([[dollhouse, ['player-width', 'player-height',
                'player-x', 'player-x']]]);
        }
        player_slot.width ??= dollhouse['player-width'];
        player_slot.height ??= dollhouse['player-height'];
        player_slot.x ??= (dollhouse['player-x'] >= 0) ? dollhouse['player-x'] -
            Math.floor(player_slot.width/2) :
            Math.floor((this.width - player_slot.width)/2);
        player_slot.y ??= (dollhouse['player-y']  >= 0) ?
            dollhouse['player-y'] - Math.floor(player_slot.height/2) :
            Math.floor((this.height - player_slot.height)/2);
        if (typeof mc().icon !== 'undefined') {
            player_slot.icon = mc().icon;
        } else {
            player_slot.color = (dollhouse['player-color']) ?
                dollhouse['player-color'] : 'blue';
        }
        if (original_dh) {
            player_slot.init(this);
        }
        this.setPlayerSlot(player_slot);
        return player_slot;
    }
    /**
     * Used to set up the Doll. This involves loading all images related to
     * doll, and initializing the has_click_listeners, has_collision_listeners,
     * has_update_listeners booleant
     */
    init()
    {
        this.num_loading = 0;
        this.render_requests = [];
        this.rendering = false;
        if (this.icon) {
            this.image = this.loadImage(this.icon);
        }
        this.slots.init(this);
        let listener_has_map = {
            'click-listener' : 'has_click_listeners',
            'collision-listener' : 'has_collision_listeners',
            'update-listener' : 'has_update_listeners'
        };
        for (const listener in listener_has_map) {
            let has_listener = listener_has_map[listener];
            if (this[listener] || this.slots[has_listener]) {
                this[has_listener] =  true;
            }
        }
    }
    /**
     * Initiates loading of the image at URL into an Image object and returns
     * this object. Adds one to num_loading property of this Doll,
     * so know Doll is tracking one more image to be loaded.
     * Sets up a callback such that if all the images that have been requested
     * for this Doll have loaded, and there have been requests to render
     * this doll, then the Doll is rendered to each canvas requested.
     *
     * @param {string} URL of image to load
     * @return {HTMLImageElement} image to be loaded
     */
    loadImage(url)
    {
        let image = new Image();
        image.src = url;
        this.num_loading++;
        image.onload = () => {
            this.num_loading--;
            if (this.num_loading <= 0) {
                this.num_loading = 0;
            } else {
                return;
            }
            if (this.render_requests.length > 0 && !this.rendering) {
                this.rendering = true;
                for (const canvas of this.render_requests) {
                    this.render(canvas);
                }
                this.render_requests = [];
                this.rendering = false;
            }
        }
        return image;
    }
    /**
     * If the passed slot is a DollSlot adds it to slots and returns true;
     * otherwise, does nothing and returns false
     * @param {DollSlot} slot to insert
     * @return {boolean} whether the item was added or not
     */
    push(slot)
    {
        return this.slots.push(slot);
    }
    /**
     * Draws the current Doll to the provided canvas view. Note given the
     * Doll's coordinate system only some portion of the Doll may be
     * visible. If not all of the images for this Doll have been downloaded
     * a render request is made instead.
     *
     * @param {HTMLCanvasElement} canvas to draw Doll onto
     */
    render(canvas)
    {
        if (!canvas) {
            return;
        }
        if (this.num_loading > 0) {
            this.render_requests.push(canvas);
            return;
        }
        const context = canvas.getContext("2d");
        let source_x = parseInt(canvas.getAttribute('data-source-x'));
        let source_y = parseInt(canvas.getAttribute('data-source-y'));
        let source_width = parseInt(canvas.getAttribute('data-source-width'));
        let source_height = parseInt(canvas.getAttribute('data-source-height'));
        let view_width = parseInt(canvas.getAttribute('width'));
        let view_height = parseInt(canvas.getAttribute('height'));
        if (is_mobile) {
            let max_mobile_width = window.screen.width - 40;
            let max_mobile_height = view_height * max_mobile_width / view_width;
            if (view_width > max_mobile_width) {
                view_width = max_mobile_width;
                view_height = max_mobile_height;
                canvas.width = view_width;
                canvas.height = view_height;
            }
        }
        let color = this.color ?? 'white';
        context.clearRect(0, 0, view_width, view_height);
        let scale_x = view_width/source_width;
        let scale_y = view_height/source_height;
        if (this.image) {
            let rect = intersectRectangles(
                [source_x, source_y, source_width, source_height],
                [0, 0, source_width, source_height]);
            let coords = imageCoordinates(rect[0], rect[1],
                rect[2], rect[3], this);
            let dest_rect = [Math.floor(scale_x * (rect[0] - source_x)),
                Math.floor(scale_y * (rect[1] - source_y)),
                Math.floor(scale_x * rect[2]),
                Math.floor(scale_y * rect[3])
            ];
            context.drawImage(this.image, coords[0], coords[1], coords[2],
                coords[3], dest_rect[0], dest_rect[1], dest_rect[2],
                dest_rect[3]);
        } else if (this.color) {
            context.fillStyle = this.color;
            context.fillRect(0, 0, view_width, view_height);
        }
        let doll_slots = this.slots.items;
        if (!doll_slots) {
            return;
        }
        for (const doll_slot of doll_slots) {
            if (typeof doll_slot == 'undefined') {
                continue;
            }
            let rect = intersectRectangles(
                [source_x, source_y, source_width, source_height],
                [doll_slot.x, doll_slot.y, doll_slot.width, doll_slot.height]);
            if (rect) {
                let dest_rect = [Math.floor(scale_x * (rect[0] - source_x)),
                    Math.floor(scale_y * (rect[1] - source_y)),
                    Math.floor(scale_x * rect[2]),
                    Math.floor(scale_y * rect[3])
                ];
                if (doll_slot.image) {
                    let coords = imageCoordinates(rect[0], rect[1],
                        rect[2], rect[3], doll_slot, true);
                    context.drawImage(doll_slot.image,
                        coords[0], coords[1],
                        coords[2], coords[3], dest_rect[0], dest_rect[1],
                        dest_rect[2], dest_rect[3]);
                } else if (typeof doll_slot.color != 'undefined') {
                    context.fillStyle = doll_slot.color;
                    context.fillRect(dest_rect[0], dest_rect[1],
                        dest_rect[2], dest_rect[3]);
                }
            }
        }
    }
    /**
     * Sets the DollSlot object that corresponds to the person playing the
     * game to player_slot. This object holds player info when a Doll is
     * navigable
     *
     * @param {DollSlot} player_slot a doll slot with player's rectangle info
     *  on the doll, and icon or color to draw for player
     */
    setPlayerSlot(player_slot)
    {
        if (this.player_index === false) {
            this.player_index = this.push(player_slot);
        } else {
            this.slots.items[this.player_index] = player_slot;
        }
    }
}

/**
 * Encapsulates the image slots a Doll might have
 */
class DollSlots
{
    /**
     * Whether any of the DollSlot object managed by this DollSlots has
     * a click listener. Such a click listener is called if a the
     * canvas used to draw a Doll is clicked and that click is within the
     * rectangle of the listening DollSlot.
     * @type {boolean}
     */
    has_click_listeners = false;
    /**
     * Whether any of the DollSlot object managed by this DollSlots has
     * a collision listener. Such a listener is called if the rectangle
     * of a listening DollSlot intersects with another DollSlot in this
     * DollSlots.
     * @type {boolean}
     */
    has_collision_listeners = false;
    /**
     * Whether any of the DollSlot object managed by this DollSlots has
     * an update listener a timer is used to call such listeners periodically
     * @type {boolean}
     */
    has_update_listeners = false;
    /**
     * Array of DollSlot objects managed by this DollSlots.
     * @type {Array}
     */
    items = [];
    /**
     * If not null, Doll on which this DollSlot lives.
     * @type {Doll?}
     */
    parent;
    /**
     * Returns the DollSlot at index i if it exists else return null
     * @param {int} DollSlot index to get
     * @return {DollSlot?} the desired DollSlot if present
     */
    get(i)
    {
        if (typeof this.items[i] != 'undefined') {
            return this.items[i];
        }
        return null;
    }
    /**
     * Calls init on each of the DollSlots in items. Initializes the
     * has_click_listeners, has_collision_listeners, and has_update_listeners
     * property based on checking for the corresponding listeners in
     * each DollSlot iterated over.
     *
     * @param {Doll?} parent Doll used to help initialize each Doll (used
     *   for Image loading (Doll needs to keep track of how many of its
     *   DollSlots still need to be loaded))
     */
    init(parent)
    {
        let listener_has_map = {
            'click-listener' : 'has_click_listeners',
            'collision-listener' : 'has_collision_listeners',
            'update-listener' : 'has_update_listeners'
        };
        for (const doll_slot of this.items) {
            if (doll_slot.type == 'DollSlot') {
                doll_slot.init(parent);
                for (const listener in listener_has_map) {
                    let has_listener = listener_has_map[listener];
                    if (doll_slot[listener]) {
                        this[has_listener] = true;
                    }
                }
            }
        }
    }
    /**
     * If the passed slot is a DollSlot adds it to items Array at index i
     * and return true; otherwise, does nothing and returns false
     * @param {int} i index in items array to insert DollSlot
     * @param {DollSlot} slot to insert
     * @return {boolean} whether the item was added or not
     */
    set(i, slot)
    {
        if (typeof slot['type'] != 'undefined' && slot['type'] == 'DollSlot') {
            this.items[i] = slot;
            return i;
        } else {
            return false;
        }
    }
    /**
     * If the passed slot is a DollSlot adds it to items Array and returns true;
     * otherwise, does nothing and returns false
     * @param {DollSlot} slot to insert
     * @return {boolean} whether the item was added or not
     */
    push(slot)
    {
        return this.set(this.items.length, slot);
    }
    /**
     * Removes and delete the DollSlot at location index from this DollSlots
     * items array of DollSlot objects
     * @param {int} index of DollSlot to remove
     */
    remove(index)
    {
        if (typeof this.items[i] != 'undefined') {
            delete this.items[index];
            return true;
        }
        return false;
    }
    /**
     * Removes the DollSlot of the given slot_id name from this DollSlots object
     *
     * @param {string} slot_id id property of the DollSlot to remove
     * @return {boolean} true if a DollSlot with the given id is found and
     *  removed, false otherwise
     */
    removeById(slot_id)
    {
        for (const i = 0; i < this.items.length; i++) {
            if (this.items[i] && this.items[i].id == slot_id) {
                delete this.items[i];
                return true;
            }
        }
        return false;
    }
}

/**
 * Encapsulates a single image slot for use in a Doll
 */
class DollSlot
{
    /**
     * The filename or object to be used by this slot in a paper Doll
     * @type {}
     */
    icon = "";
    /**
     * The image to be used by this slot in a paper Doll
     * @type {Image}
     */
    image = null;
    /**
     * The height of the slot in a paper Doll
     * @type {int}
     */
    height;
    /**
     * The width of the slot in a paper Doll
     * @type {int}
     */
    width;
    /**
     * The x coordinate within the overall Doll for this DollSlot
     * @type {int}
     */
    x;
    /**
     * The y coordinate within the overall Doll for this DollSlot
     * @type {int}
     */
    y;
    /**
     * Initializes this DollSlot within its parent Doll, parent_doll.
     * This involves loading any Image associated with this DollSlot
     * or discarding any image, if the DollSlot is going to be presented
     * as a solid color.
     */
    init(parent_doll)
    {
        if (this.icon) {
            this.image = parent_doll.loadImage(this.icon);
        } else if (this.image) {
            delete this.image;
        }
    }
}
/**
 * Class used to encapsulate an interactive story game. It has fields
 * to track the locations and objects in the game, the history of moves of
 * the game, and how many moves have been made. It has methods to
 * take a turn in such a game, to save state, load state,
 * restore prev/next state from history, render the state of such
 * a game.
 */
class Game
{
    /**
     * If there are any dollhouses currently being drawn which are updateable
     * this gives the default refresh rate in milliseconds
     * @type {int}
     */
    dollhouse_refresh_rate = 50;
    /**
     * ids of keyboard and setTimeouts that are currently updating
     * dollhouses (if any) in the the location being presented
     * @type {Array<int>}
     */
    dollhouse_update_ids = [];
    /**
     * A semi-unique identifier for this particular game to try to ensure
     * two different games hosted in the same folder don't collide in
     * sessionStorage.
     * @type {string}
     */
    id;
    /**
     * Whether game page was just reloaded
     * @type {boolean}
     */
    reload;
    /**
     * Current date followed by a space followed by the current time of
     * the most recent game capture. Used in providing a description of
     * game saves.
     * @type {number}
     */
    timestamp;
    /**
     * A counter that is incremented each time Javascript draws a new
     * clickProceed a tag. Each such tag is given an id, tick is used to ensure
     * these id's are unique.
     * @type {number}
     */
    tick = 0;
    /**
     * Whether this particular game has a nav bar or not
     * @type {boolean}
     */
    has_nav_bar;
    /**
     * List of all Game Object's managed by the FRISE script. An object
     * can be used to represent a thing such as a person, tool, piece of
     * clothing, letter, etc. In an HTML document, a game object is defined
     * using  an x-object tag.
     * @type {Array<Object>}
     */
    objects;
    /**
     * List of all game Location's managed by the FRISE script. A Location
     * can be used to represent a place the main character can go. This
     * could be standard locations in the game, as well as Locations
     * like a Save page, Inventory page, Status page, etc.
     * In an HTML document a game Location is defined using an x-location tag.
     * @type {Array<Location>}
     */
    locations;
    /**
     *
     *
     * @type {Array<Doll>}
     */
    dolls;
    /**
     * Used to maintain a stack (using Array push/pop) of Game State Objects
     * based on the turns the user has taken (the top of the stack corresponds
     * to the previous turn). A Game State Object is a serialized string:
     *  {
     *    timestamp: capture_time,
     *    objects: array_of_game_objects_at_capture_time,
     *    locations: array_of_game_locations_at_capture_time,
     *  }
     * @type {Array}
     */
    history;
    /**
     * Used to maintain a stack (using Array push/pop) of Game State Objects
     * based on the the number previous turn clicks the user has done.
     * I.e., when a user clicks previous turn, the current state is pushed onto
     * this array so that if the user then clicks next turn the current
     * state can be restored.
     * A Game State Object is a serialized string:
     *  {
     *    timestamp: capture_time,
     *    objects: array_of_game_objects_at_capture_time,
     *    locations: array_of_game_locations_at_capture_time,
     *  }
     * @type {Array}
     */
    future_history;
    /**
     * Id of first room main-character is in;
     * @type {String}
     */
    base_location;
    /**
     * Is set to true just before a default action for a location the
     * main character is at is executed; otherwise, false
     * @type {Boolean}
     */
    is_here;
    /**
     * List of id's of buttons and links to disable during the staging
     * phases of rendering a presentation or when viewing those locations.
     * @type {Array}
     */
    volatile_links = ['saves', 'inventory'];
    /**
     * Sets up a game object with empty history, an initialized main navigation,
     * and with objects and locations parsed out of the current HTML file
     */
    constructor()
    {
        let title_elt = tag('title')[0];
        if (!title_elt) {
            title_elt = tag('x-game')[0];
        }
        this.reload = false; //current
        let doc_length = 0;
        let middle_five = "";
        if (title_elt) {
            doc_length = title_elt.innerHTML.length;
            if (doc_length > 8) {
                let half_length = Math.floor(doc_length/2);
                middle_five = title_elt.innerHTML.slice(
                    half_length, half_length + 5);
            }
        }
        // a semi-unique code for this particular game
        this.id = encodeURI(middle_five + doc_length);
        this.initializeMainNavGameContentArea();
        this.initializeObjectsLocationsDolls();
        this.clearHistory();
    }
    /**
     * Writes to console information about which objects and locations
     * might not be properly defined.
     */
    debug()
    {
        let none = "none";
        console.log("Game objects without position:");
        for (let obj of Object.values(this.objects)) {
            if (!obj.has_position) {
                console.log("  " + obj.id);
                none = "";
            }
        }
        if (none) {
            console.log("  " + none);
        }
        none = "none";
        console.log("Game locations without x-present:");
        for (loc of  Object.values(this.locations)) {
            if (!loc.has_present) {
                console.log("  " +loc.id);
                none = "";
            }
        }
        none = "none";
        console.log("Game dolls missing required attributes:");
        for (const doll_obj of  Object.values(this.dolls)) {
            if (!doll_obj.has_icon && !doll_obj.has_color) {
                console.log("  " + doll_obj.icon + " has no icon or color");
                none = "";
            }
            if (!doll.has_height) {
                console.log("  " + doll_obj.height + " has no height");
                none = "";
            }
            if (!doll.has_width) {
                console.log("  " + doll_obj.width + " has no width");
                none = "";
            }
        }
        if (none) {
            console.log("  " + none);
        }
        return true;
    }
    /**
     * Used to reset the game to the condition at the start of a game
     */
    reset()
    {
        sessionStorage.removeItem("current" + this.id);
        this.initializeMainNavGameContentArea();
        this.initializeObjectsLocationsDolls();
        this.clearHistory();
    }
    /**
     * Sets up the main navigation bar and menu on the side of the screen
     * determined  by the is_right_to_left variable. Sets up an initially empty
     * game content area which can be written to by calling a Location
     * object's renderPresentation. The main navigation consists of a hamburger
     * menu toggle button for the navigation as well as previous
     * and next history arrows at the top of screen. The rest of the main
     * navigation content is determined by the contents of the x-main-nav
     * tag in the HTML file for the game. If this tag is not present, the
     * game will not have a main navigation bar and menu.
     */
    initializeMainNavGameContentArea()
    {
        let body_objs = tag("body");
        if (body_objs[0] === undefined) {
            return;
        }
        let body_obj = body_objs[0];
        let game_screen = elt('game-screen');
        if (!game_screen) {
            body_obj.innerHTML = '<div id="game-screen"></div>' +
                body_obj.innerHTML;
            game_screen = elt('game-screen');
        }
        let main_nav_objs = tag("x-main-nav");
        if (typeof main_nav_objs[0] === "undefined") {
            game_screen.innerHTML = `<div id="game-content" tabindex="0">
                </div>`;
            this.has_nav_bar = false;
            return;
        }
        this.has_nav_bar = true;
        let main_nav_obj = main_nav_objs[0];
        let history_buttons;
        if (is_right_to_left) {
            history_buttons =
                `<button id="previous-history">→</button>
                 <button id="next-history">←</button>`;
        } else {
            history_buttons =
                `<button id="previous-history">←</button>
                 <button id="next-history">→</button>`;
        }
        game_screen.innerHTML = `
            <div id="main-bar">
            <button id="main-toggle"
            class="float-left"><span class="main-close">≡</button>
            </div>
            <div id="main-nav">
            ${history_buttons}
            <div id="game-nav">
            ${main_nav_obj.innerHTML}
            </div>
            </div>
            <div id="game-content"></div>`;
        this.initializeGameNavListeners();
    }
    /**
     * Used to initialize the event listeners for the next/previous history
     * buttons. It also adds listeners to all the a tag and x-button tags
     * to process their href attributes before following any link is followed
     * to its target.
     * @see addListenersAnchors
     */
    initializeGameNavListeners()
    {
        elt('main-toggle').onclick = (evt) => {
            toggleMainNav('main-nav', 0);
        };
        elt('next-history').onclick = (evt) => {
            this.nextHistory();
        }
        elt('previous-history').onclick = (evt) => {
            this.previousHistory();
        }
        let anchors = sel('#game-nav a, #game-nav x-button');
        addListenersAnchors(anchors, is_mobile);
    }
    /**
     * Checks if the game is being played on a mobile device. If not, this
     * method does nothing, If it is being played on a mobile device,
     * then this method sets up the viewport so that the HTML will
     * display properly. Also, in the case where the game is being played on a
     * mobile device, this method also sets it so the main nav bar on the side
     * of the screen is closed.
     */
    initializeScreen()
    {
        let html = tag("html")[0];
        if (is_right_to_left) {
            html.classList.add("rtl");
        }
        if (!this.has_nav_bar) {
            html.classList.add("no-nav");
        }
        let is_slides = sel("x-slides")[0] ? true : false;
        if (is_slides) {
            html.classList.add("slides");
        }
        if(!is_mobile) {
            return;
        }
        html.classList.add("mobile");
        let head = tag("head")[0];
        head.innerHTML += `<meta name="viewport" `+
            `content="width=device-width, initial-scale=1.0" >`;
        if (this.has_nav_bar) {
            toggleMainNav();
        }
    }
    /**
     * For each object, if object.position is defined, then adds the object
     * to the location.item array of the Location whose id is
     * given by object.position. Sets up the dolls Array of paperdolls
     * used in the game (so their images start loading).
     */
    initializeObjectsLocationsDolls()
    {
        this.objects = xtag("x-object");
        this.locations = xtag("x-location");
        this.dolls = xtag('x-doll');
        for (const oid in this.objects) {
            let object = this.objects[oid];
            if (object.hasOwnProperty("position")) {
                let location_name = object.position;
                if (this.locations.hasOwnProperty(location_name)) {
                    let location = this.locations[location_name]
                    if (!location.hasOwnProperty("items")) {
                        location.items = [];
                    }
                    location.items.push(object.id);
                }
                if (typeof object.original_position == 'undefined') {
                    object.original_position = object.position;
                }
            }
        }
        for (const lid in this.locations) {
            let location = this.locations[lid];
            if (!location.hasOwnProperty("items")) {
                location.items = [];
            }
            if (typeof location.original_items == 'undefined') {
                location.original_items = location.items;
            }
        }
    }
    /**
     * Creates a JSON encoded string representing the current state of
     * the game (all of the object and location states and where the main
     * character is).
     *
     * @return {string} JSON encoded current state of game.
     */
    captureState()
    {
        let now = new Date();
        let date = now.getFullYear() + '-' + (now.getMonth() + 1) +
            '-' + now.getDate();
        let time = now.getHours() + ":" + now.getMinutes() + ":"
                + now.getSeconds();
        game.timestamp = date + " " + time;
        return JSON.stringify({
            timestamp: game.timestamp,
            base_location: game.base_location,
            objects: this.objects,
            locations: this.locations
        });
    }
    /**
     * Sets the current state of the game (current settings for all objects,
     * locations, and main character position), based on the state given in
     * a JSON encode string representing a game state.
     *
     * @param {string} gave_save a JSON encoded state of the a FRISE game.
     */
    restoreState(game_save)
    {
        let game_state = JSON.parse(game_save);
        if (!game_state || !game_state.timestamp ||
            !game_state.objects || !game_state.locations) {
            alert(tl[locale]['restore_state_invalid_game']);
            return false;
        }
        this.timestamp = game_state.timestamp;
        this.base_location = game_state.base_location;
        /*
          during development, changing an object or location's text might
          not be viewable on a reload unless we copy some fields of the
          reparsed html file into a save game object.
         */
        let old_objects = this.objects;
        this.objects = game_state.objects;
        for (const field in old_objects) {
            if (!this.objects.hasOwnProperty(field)) {
                /* we assume our game never deletes objects or locations, so
                   if we find an object in old_objects (presumably it's coming
                   from a more recently parsed HTML file) that was not
                   in the saved state, we copy it over.
                 */
                this.objects[field] = old_objects[field];
            } else {
                if (old_objects.hasOwnProperty('action')) {
                    this.objects['action'] = old_objects['action'];
                } else if (this.objects.hasOwnProperty('action')) {
                    delete this.objects['action'];
                }
            }
        }
        let old_locations = this.locations;
        let locations = game_state.locations;
        let location;
        this.locations = {};
        for (const location_name in old_locations) {
            if (!locations.hasOwnProperty(location_name)) {
                location = old_locations[location_name];
            } else {
                let location_object = locations[location_name];
                location = new Location();
                for (const field in location_object) {
                    location[field] = location_object[field];
                    if (field == 'present' || field == 'action' ||
                        field == 'default-action') {
                        if (!old_locations[
                            location_name].hasOwnProperty(field)) {
                            delete location[field];
                        } else {
                            location[field] =
                                old_locations[location_name][field];
                        }
                    }
                }
            }
            this.locations[location_name] = location;
        }
        return true;
    }
    /**
     * Deletes the game state capture history for the game. After this
     * calling this method, the game's next and previous arrow buttons
     * won't do anything until new turns have occurred.
     */
    clearHistory()
    {
        this.history = [];
        this.future_history = [];
        let next_history_elt = elt('next-history');
        if (next_history_elt) {
            next_history_elt.disabled = true;
            elt('previous-history').disabled = true;
        }
    }
    /**
     * Called when the left arrow button on the main nav page is
     * clicked to go back one turn in the game history. Pushes the current
     * game state to the future_history game state array, then pops the most
     * recent game state from the history game state array and sets it as
     * the current state.
     */
    previousHistory()
    {
        if (this.history.length == 0) {
            return;
        }
        let current_state = this.captureState();
        this.future_history.push(current_state);
        let previous_game_state = this.history.pop();
        this.restoreState(previous_game_state);
        sessionStorage["current" + this.id] = previous_game_state;
        this.describeMainCharacterLocation();
        if (this.history.length == 0) {
            elt('previous-history').disabled = true;
        } else {
            elt('previous-history').disabled = false;
        }
        elt('next-history').disabled = false;
    }
    /**
     * Called when the right arrow button on the main nav page is
     * clicked to go forward one turn in the game history (assuming the user had
     * clicked previous at least once). Pushes the current game state
     * to the history game state array, then pops the game state from the
     * future_history game state array and sets it as the current state.
     */
    nextHistory()
    {
        if (this.future_history.length == 0) {
            return;
        }
        let current_state = this.captureState();
        this.history.push(current_state);
        let next_game_state = this.future_history.pop();
        this.restoreState(next_game_state);
        sessionStorage["current" + this.id] = next_game_state;
        this.describeMainCharacterLocation();
        if (this.future_history.length == 0) {
            elt('next-history').disabled = true;
        } else {
            elt('next-history').disabled = false;
        }
        elt('previous-history').disabled = false;
    }
    /**
     * Initializes the save slots for the saves location page of a game.
     * This involves looking at session storage and determining which slots
     * have games already saved to them, and for those slots, determining also
     * what time the game was saved.
     */
    initSlotStates()
    {
        let saves_location = game.locations['saves'];
        for (const field in saves_location) {
            let slot_matches = field.match(/^slot(\d+)/);
            if (slot_matches && slot_matches[1]) {
                let slot_number = parseInt(slot_matches[1]);
                let game_save = localStorage.getItem("slot" + game.id
                    + slot_number);
                if (game_save) {
                    let game_state = JSON.parse(game_save);
                    saves_location["slot" + slot_number] =
                        tl[locale]['init_slot_states_load'];
                    saves_location["delete" + slot_number] = "";
                    saves_location["filled" + slot_number] = 'filled';
                    saves_location["filename" + slot_number] =
                        game_state.timestamp;
                } else {
                    saves_location["slot" + slot_number] =
                        tl[locale]['init_slot_states_save'];
                    saves_location["filled" + slot_number] = 'not-filled';
                    saves_location["delete" + slot_number] = "disabled";
                    saves_location["filename" + slot_number] = '...';
                }
            }
        }
    }
    /**
     * Saves the current game state to a localStorage save slot if that
     * slot if empty; otherwise, if the slot has data in it, then sets
     * the current game state to the state stored at that slot. When saving,
     * this method also records the timestamp of the save time to the
     * game's saves location.
     *
     * @param {number} slot_number
     */
    saveLoadSlot(slot_number)
    {
        slot_number = parseInt(slot_number);
        let saves_location = game.locations['saves'];
        let game_state = localStorage.getItem("slot" + game.id + slot_number);
        if (game_state) {
            this.clearHistory();
            sessionStorage["current" + game.id] = game_state;
            this.restoreState(game_state);
        } else {
            let save_state = this.captureState();
            game_state = this.history[this.history.length - 1];
            this.restoreState(game_state);
            saves_location['filename' + slot_number] =  this.timestamp;
            localStorage.setItem("slot" + game.id + slot_number, game_state);
            this.restoreState(save_state);
            this.evaluateAction(saves_location['default-action']);
        }
    }
    /**
     * Deletes any game data from localStorage at location
     * "slot" + slot_number.
     *
     * @param {number} slot_number which save game to delete. Games are stored
     *   at a localStorage field "slot" + slot_number where it is intended
     *   (but not enforced) that the slot_number be an integer.
     */
    deleteSlotData(slot_number)
    {
        slot_number = parseInt(slot_number);
        let saves_location = game.locations['saves'];
        localStorage.removeItem("slot" + game.id + slot_number);
        saves_location['filled' + slot_number] = "not-filled";
        saves_location['delete' + slot_number] = "disabled";
        saves_location['slot' + slot_number] =
            tl[locale]['init_slot_states_save'];
        saves_location['filename' + slot_number] =  "...";
    }
    /**
     * Deletes any game data from localStorage at location
     * "slot" + slot_number, updates the game's saves location to reflect the
     * change.
     *
     * @param {number} slot_number which save game to delete. Games are stored
     *   at a localStorage field "slot" + slot_number where it is intended
     *   (but not enforced) that the slot_number be an integer.
     */
    deleteSlot(slot_number)
    {
        this.deleteSlotData(slot_number);
        let saves_location = game.locations['saves'];
        this.evaluateAction(saves_location['default-action']);
    }
    /**
     * Deletes all game saves from sessionStorage
     */
    deleteSlotAll()
    {
        let i = 0;
        let saves_location = game.locations['saves'];
        while (saves_location.hasOwnProperty('filename' + i)) {
            this.deleteSlotData(i);
            i++;
        }
        this.evaluateAction(saves_location['default-action']);
    }
    /**
     * Launches a file picker to allow the user to select a file
     * containing a saved game state, then tries to load the current game
     * from this file.
     */
    load()
    {
        let file_load = elt('file-load');
        if (!file_load) {
            file_load = document.createElement("input");
            file_load.type = "file";
            file_load.id = 'file-load';
            file_load.style.display = 'none';
            file_load.addEventListener('change', (event) => {
                let to_read = file_load.files[0];
                let file_reader = new FileReader();
                file_reader.readAsText(to_read, 'UTF-8')
                file_reader.addEventListener('load', (load_event) => {
                    let game_state = load_event.target.result;
                    this.clearHistory();
                    sessionStorage["current" + this.id] = game_state;
                    this.restoreState(game_state);
                    game.describeMainCharacterLocation();
                });
            });
        }
        file_load.click();
    }
    /**
     * Creates a downloadable save file for the current game state.
     */
    save()
    {
        let game_state = this.history[this.history.length - 1];
        let file = new Blob([game_state], {type: "plain/text"});
        let link = document.createElement("a");
        link.href = URL.createObjectURL(file);
        link.download = "game_save.txt";
        link.click();
    }
    /**
     * Computes one turn of the current game based on the provided url hash
     * fragment. A url hash fragment is the part of the url after a # symbol.
     * In non-game HTML, #fragment is traditionally used to indicate the browser
     * should show the page as if it had been scrolled to where the element
     * with id attribute fragment is. In a FRISE game, a fragment has
     * the form #action_1_name;action_2_name;...;action_n_name;next_location_id
     * Such a fragment when processed by takeTurn will cause the Javascript in
     * x-action tags with id's action_1_name, action_2_name,...,action_n_name
     * to be invoked in turn. Then the main-character object is moved to
     * location next_location_id.  If the fragment, only consists of
     * 1 item, i.e., is of the form, #next_location_id, then this method
     * just moves the main-character to next_location_id.
     * After carrying out the action and moving the main-character,
     * takeTurn updates the game state history and future_history
     * accordingly. Then for each object and each location,
     * if the object/location, has an x-default-action tag, this default action
     * is executed. Finally, the Location of the main-character is presented
     * (its renderPresentation is called).
     * takeTurn supports two special case action #previous and #next
     * which move one step back or forward (if possible) in the Game state
     * history.
     * @param {string} hash url fragment ot use when computing one turn of the
     *  current game.
     */
    takeTurn(hash)
    {
        let new_game_state;
        let is_slides = sel("x-slides")[0] ? true : false;
        if (!is_slides) {
            if (this.has_nav_bar) {
                if (hash == "#previous") {
                    this.previousHistory();
                    return;
                } else if (hash == "#next") {
                    this.nextHistory();
                    return;
                }
            }
            if (sessionStorage["current" + game.id]) {
                new_game_state = sessionStorage["current" + game.id];
            }
        }
        if (!this.moveMainCharacter(hash)) {
            return;
        }
        if (!is_slides) {
            this.future_history = [];
            if (this.has_nav_bar) {
                elt('next-history').disabled = true;
            }
            if (sessionStorage["current" + game.id]) {
                this.history.push(new_game_state);
            }
            this.evaluateDefaultActions(this.objects);
            this.evaluateDefaultActions(this.locations);
            sessionStorage["current" + game.id] = this.captureState();
        }
        this.describeMainCharacterLocation();
        if (!is_slides) {
            game.reload = false;
            if (this.has_nav_bar) {
                if (this.history.length == 0) {
                    elt('previous-history').disabled = true;
                } else {
                    elt('previous-history').disabled = false;
                }
            }
        }
    }
    /**
     * For each game Object and each game Location in x_entities evaluate the
     * Javascript (if it exists) of its default action (from its
     * x-default-action tag).
     *
     * @param {Array} of game Object's or Location's
     */
    evaluateDefaultActions(x_entities)
    {
        for (const object_name in x_entities) {
            let game_entity = x_entities[object_name];
            if (mc().position == object_name && game_entity
                instanceof Location) {
                game['is_here'] = true;
            } else {
                game['is_here'] = false;
            }
            if (game_entity && game_entity['default-action']) {
                this.evaluateAction(game_entity['default-action']);
            }
        }
    }
    /**
     * Moves a game Object to a new game Location. If the object had a
     * previous location, then also deletes the object from there.
     *
     * @param {string} object_id of game Object to move
     * @param {string} destination_id of game Location to move it to
     */
    moveObject(object_id, destination_id)
    {
        let move_object = this.objects[object_id];
        if (!move_object || !this.locations[destination_id]) {
            alert(tl[locale]['move_object_failed'] +
                "\nmoveObject('" + object_id + "', '" + destination_id + "')");
            return false;
        }
        if (move_object.hasOwnProperty("position")) {
            let old_position = move_object.position;
            let old_location = this.locations[old_position];
            old_location.items = old_location.items.filter((value) => {
                return value != object_id;
            });
        }
        move_object.position = destination_id;
        let new_location = this.locations[destination_id];
        if (!new_location.items) {
            new_location.items = [];
        }
        new_location.items.push(object_id);
        return true;
    }
    /**
     * Moves the main character according to the provided url fragment.
     *
     * @param {string} hash a url fragment as described above
     */
    moveMainCharacter(hash)
    {
        if (!hash || hash <= 1) {
            return true;
        }
        hash = hash.substring(1);
        let hash_parts = hash.split(/\s*\;\s*/);
        let destination = hash_parts.pop();
        for (const hash_part of hash_parts) {
            let hash_matches = hash_part.match(/([^\(]+)(\(([^\)]+)\))?\s*/);
            let action = elt(hash_matches[1]);
            let args = [];
            if (typeof hash_matches[3] !== 'undefined') {
                args = hash_matches[3].split(",");
            }
            if (action && action.tagName == 'X-ACTION'
                || (action.tagName == 'SCRIPT' &&
                    action.getAttribute('type') == 'text/action')) {
                let code = action.innerHTML;
                if (code) {
                    this.evaluateAction(code, args);
                }
            }
        }
        if (destination == "exit") {
            this.describeMainCharacterLocation();
            return false;
        }
        if (destination == "previous") {
            this.previousHistory();
            return false;
        }
        if (destination == "next") {
            this.nextHistory();
            return false;
        }
        let mc = obj('main-character');
        if (mc.position != mc.destination && mc.position != 'saves') {
            mc.old_position = mc.position;
        }
        if (this.dollhouse_update_ids.length > 0) {
            for (const update_id of this.dollhouse_update_ids) {
                clearTimeout(update_id);
            }
            this.dollhouse_update_ids = [];
        }
        this.moveObject('main-character', destination);
        this.locations[mc.position].visited++;
        return true;
    }
    /**
     * Given a string holding pre-Javascript code from an x-action tag,
     * evaluates the code. If this function  is passed additional arguments
     * then an args array is set up that can be used as a closure variable for
     * this eval call.
     *
     * @param {string} Javascript code.
     */
    evaluateAction(code)
    {
        var args = [];
        if (arguments.length > 1) {
            if (arguments[1]) {
                args = arguments[1];
            }
        }
        eval(code);
    }
    /**
     * Used to present the location that the Main Character is currently at.
     */
    describeMainCharacterLocation()
    {
        let main_character = this.objects['main-character'];
        let position = main_character.position;
        let location = this.locations[position];
        location.renderPresentation();
    }
    /**
     * Return the array of link ids which should be disable while performing
     * the staging of a presentation
     *
     * @return {Array}
     */
    volatileLinks()
    {
        return this.volatile_links;
    }
}
/**
 * Module initialization function used to set up the game object corresponding
 * to the current HTML document. It first loads any x-included game fragments
 * before calling @see finishInitGame to perform the rest of the game
 * initialization process
 */
async function initGame()
{
    loadIncludes(finishInitGame);
}
/**
 * This module initialization is called to initialize FRISE when it is being
 * used to show a slide presentation. It first loads any x-included slideshow
 * fragments before calling @see finishInitSlides to perform the rest of the
 * game initialization process
 */
async function initSlides()
{
    loadIncludes(finishInitSlides);
}
/**
 * Function used to load any x-included game fragments into this game.
 * This function is run before the rest of the game's initialization is
 * done in @see finishInitGame or finishInitSlides
 *
 * @param {Function} callback called to complete game initialization after
 *      the included game fragments are laoded. This is typically either
 *      finishInitGame or finishInitSlides
 */
async function loadIncludes(callback)
{
    let includes = sel("x-include");
    includes_to_load = includes.length;
    if (includes_to_load == 0) {
        return callback();
    }
    for (const include_node of includes) {
        if (include_node.nextSibling.nodeName == 'X-LOADED') {
            includes_to_load--;
            continue;
        }
        let url = include_node.textContent.trim();
        fetch(url).then(response => response.text()).then(
            data => {
                let loaded_node = document.createElement('x-loaded');
                loaded_node.innerHTML = data;
                include_node.parentNode.insertBefore(loaded_node,
                    include_node.nextSibling);
                includes_to_load --;
                if (includes_to_load <= 0) {
                    if (window.location.search == "?view-source") {
                        let encodeEntities = (text) => {
                            let textArea = document.createElement('textarea');
                            textArea.innerText = text;
                            return textArea.innerHTML;
                        }
                        let source = "<!doctype html>\n" +
                            document.documentElement.outerHTML;
                        source = encodeEntities(source);
                        let body = sel('body')[0];
                        body.innerHTML = `<pre><code>${source}</pre></code>`;
                    } else {
                        callback();
                    }
                }
            }
        );
    }
    if (includes_to_load <= 0) {
        if (window.location.search == "?view-source") {
            alert("<!DOCTYPE html>\n" + document.documentElement.outerHTML);
        } else {
            callback();
        }
    }
}
/**
 * This function is called after all images for Doll's have
 * loaded to finish initializing the Frise Game. To do this, if there is a
 * current game state in sessionStorage it is used to initialize the game
 * state, otherwise,* the game state is based on the start of the game. After
 * this state is set up, the current location is drawn to the game content area.
 */
async function finishInitGame()
{
    game = new Game();
    /*
      Any game specific customizations are assumed to be in the function
      localInitGame if it exists
     */
    if (typeof localInitGame == 'function') {
        localInitGame();
    }
    let use_session = false;
    let is_slides = sel("x-slides")[0] ? true : false;
    if (sessionStorage["current" + game.id] && !is_slides) {
        use_session = true;
        game.restoreState(sessionStorage["current" + game.id]);
        game.reload = true;
    } else {
        game.base_location = game.objects['main-character'].position;
    }
    if (is_slides) {
        window.addEventListener('hashchange', (event) => {
            game.takeTurn(window.location.hash);
        })
    }
    game.takeTurn("");
    game.clearHistory();
    game.initializeScreen();
}
/**
 * If the current game is a slideshow presentation
 * this function is called after all images for Doll's have
 * loaded to finish initializing the slides for the presentation. This
 * sets up the forward, backward, table of conents, all slides on a single
 * page, and reset buttons as well as keyboard listeners to go this through
 * slides
 */
async function finishInitSlides()
{
    let slides = sel('x-slides')[0];
    let home = sel(`link[rel=prev], meta[name=parent],
        meta[property=parent]`)[0] ?? "";
    if (home) {
        home = home.getAttribute('content') ??
            home.getAttribute('href');
        if (home) {
            home = `<x-button href='${home}'>🏠</x-button>`;
        }
    }
    let slides_in_transition = slides.getAttribute("in-transition");
    let slides_out_transition = slides.getAttribute("out-transition");
    let slides_duration = slides.getAttribute("duration");
    slides_duration = (slides_duration) ? slides_duration : "500";
    let slides_iterations = slides.getAttribute("iteration");
    let slides_origin = slides.getAttribute("origin");
    let slides_children = slides.children;
    if (!slides_children) {
        return;
    }
    let slide_titles = sel('x-slides x-slide h1:first-of-type');
    let x_game = sel('x-game')[0];
    if (!x_game) {
        x_game = document.createElement('x-game');
        let body = sel('body')[0];
        if (!body) {
            return;
        }
        body.appendChild(x_game);
    }
    let i = 1;
    let num_slides = 0;
    let start_slide = "";
    let all_slides = "<x-present><div class='all'>";
    let title_location = `<x-location id="(titles)"><x-present>
        <h1>${tl['en']['slide_titles']}</h1><ol style="font-size:130%;">`;
    let game_contents = `<x-object id="main-character">
       <x-position>(1)</x-position></x-object>`;
    for (const slide of slides_children) {
        if (slide.tagName == 'X-SLIDE') {
            num_slides++;
        }
    }
    for (const slide_title of slide_titles) {
        title_location += `<li><a href='#(${i})'
            >${slide_title.textContent}</a></li>`;
        i++;
    }
    let title_slide_footer = ` <span class="big-p">(</span>
        <x-button href="#(1)">⟳</x-button>
        <x-button class="slides-titles" href="#(1)">-/${num_slides}</x-button>
        <x-button href="#(all)">≡</x-button>
        <span class="big-p">)</span> `;
    title_location += `</ol></x-present><x-screen-footer
        ><div class='slide-footer center'
        >${title_slide_footer}</div></x-screen-footer></x-location>`;
    game_contents += title_location;
    i = 1;
    let slide_footer, all_slide_footer;
    for (const slide of slides_children) {
        if (slide.tagName == 'X-SLIDE') {
            let in_transition = slide.getAttribute('in-transition');
            in_transition ??= (slides_in_transition ?? "");
            let out_transition = slide.getAttribute('out-transition');
            out_transition ?? (out_transition ?? slides_out_transition);
            let all_class_attribute = (slide.className) ?
                ` class='slide ${slide.className}' `: ` class='slide' `;
            let class_attribute = (slide.className) ?
                ` class='slide ${slide.className} ${in_transition}' `:
                ` class='slide ${in_transition}' `;
            let styles = slide.getAttribute('style');
            let style_attribute = (styles) ? ` style='${styles}' ` : "";
            let duration = slide.getAttribute('duration');
            duration = (duration) ? duration : slides_duration;
            let iterations = slide.getAttribute('iterations');
            iterations = (iterations) ? iterations : slides_iterations;
            let origin = slide.getAttribute('origin');
            origin = (origin) ? origin : slides_origin;
            if (out_transition) {
                out_transition += ` data-transition="${out_transition}" `;
            } else {
                out_transition = "";
            }
            if (duration) {
                out_transition += ` data-duration="${duration}" `;
            }
            if (iterations) {
                out_transition += ` data-iterations="${iterations}" `;
            }
            if (origin) {
                out_transition += ` data-transform-origin="${origin}" `;
            }
            let slide_contents = `<div ${class_attribute} ${style_attribute}>
                ${slide.innerHTML}</div>`;
            let current_slide = `<x-present>${slide_contents}`;
            let all_slide_contents = `<div ${all_class_attribute}
                ${style_attribute}>${slide.innerHTML}</div>`;
            if (i > 1) {
                slide_footer = `<x-button class="slides-previous"
                    href="#(${i - 1})" ${out_transition}>←</x-button>`;
            } else {
                slide_footer = `<x-button class="hidden">←</x-button>`
            }
            slide_footer += ` <span class="big-p">(</span>
                <x-button class="slides-restart" href="#(1)">⟳</x-button>
                ${home} <x-button class="slides-titles" href="#(titles)"
                    >${i}/${num_slides}</x-button>
                <x-button class="all-slides" href="#(all)">≡</x-button>
                <span class="big-p">)</span>`;
            if (i < num_slides) {
                slide_footer += `<x-button class="slides-next"
                    href="#(${i + 1})" ${out_transition}>→</x-button>`
            } else {
                slide_footer += `<x-button class="hidden">→</x-button>`
            }
            all_slides += all_slide_contents;
            if (i < num_slides) {
                all_slides +=`<div class="page-break"><hr></div>`;
            }
            current_slide += `</x-present>`;
            game_contents += `<x-location id="(${i})">
                ${current_slide}<x-screen-footer>
                    <div class='slide-footer center'
                    >${slide_footer}</div></x-screen-footer>
                </x-location>`;
            i++;
        }
    }
    all_slide_footer = `<div class='slide-footer center'>
        <span class="big-p">(</span>
        <x-button class="slides-restart" href="#(1)">⟳</x-button>
        <x-button class="slides-titles" href="#(titles)"
            >-/${num_slides}</x-button>
        <x-button class="all-slides" href="#(1)">≡</x-button>
        <span class="big-p">)</span></div>`;
    all_slides += `</div></x-present>`;
    game_contents += `<x-location id="(all)">
        ${all_slides}<x-screen-footer>
        <div class='slide-footer center'
            >${all_slide_footer}</div></x-screen-footer>
        </x-location>`;
    x_game.innerHTML = game_contents;
    let body = sel('body')[0];
    let start_x = 0;
    let start_swipe_ck = (e) => {
        start_x = (e.changedTouches ? e.changedTouches[0] : e).clientX;
    };
    let end_swipe_ck = (e) => {
        let dx = (e.changedTouches ? e.changedTouches[0] : e).clientX -
            start_x;
        if (dx > 30) {
            let previous = sel('#screen-footer .slides-previous')[0];
            if (previous) {
                previous.click();
            }
        } else if (dx < -30 ) {
            let next = sel('#screen-footer .slides-next')[0];
            if (next) {
                next.click();
            }
        }
    }
    body.addEventListener('touchstart', start_swipe_ck, false);
    body.addEventListener('touchend', end_swipe_ck, false);
    body.addEventListener('keydown', (event) => {
        let hash = window.location.hash ?? 1;
        let page_num = parseInt(hash.substring(2, hash.length - 1));
        page_num = (isNaN(page_num)) ? 1 : page_num;
        if (['ArrowLeft'].includes(event.code) &&  page_num > 1) {
            let previous = sel('#screen-footer .slides-previous')[0];
            if (previous) {
                previous.click();
            }
        } else  if (['Space', 'ArrowRight', 'Enter'].includes(event.code)
            && page_num < num_slides) {
                let next = sel('#screen-footer .slides-next')[0];
                if (next) {
                    next.click();
                }
        } else if (['KeyT'].includes(event.code)) {
            let titles = sel('#screen-footer .slides-titles')[0];
            if (titles) {
                titles.click();
            }
        } else if (['KeyF', 'KeyH'].includes(event.code)) {
            let slide_footer = sel('#game-screen .slide-footer')[0];
            if (slide_footer.style.display != 'none') {
                slide_footer.style.display = 'none';
                sessionStorage['show_footer'] = false;
            } else {
                slide_footer.style.display = 'block';
                sessionStorage['show_footer'] = true;
            }
        } else if (event.code == 'KeyR' || event.code == 'Digit1') {
            window.location = `#(1)`;
        } else if (event.code == 'KeyA') {
            if (mc().position == '(all)') {
                window.location = "#(1)";
            } else {
                window.location = "#(all)";
            }
        }
    });
    finishInitGame();
    if (window.location.hash) {
        game.takeTurn(window.location.hash);
    }
}
window.addEventListener("load", (event) => {
    let body = sel("body")[0];
    let has_onload = body.getAttribute("onload") ? true : false;
    let is_slides = sel("x-slides")[0] ? true : false;
    let is_game = sel("x-game")[0] ? true : false;
    if (has_onload) {
        return;
    } else if (is_slides) {
        initSlides();
    } else if (is_game) {
        initGame();
    }
});
