Last commit for js/game.js: fb5e332b99297b90d645d0cc8a12c9a78b304b53

Fix a regression in adding listener to achor element in a game'

Chris Pollett [2024-07-26 02:Jul:th]
Fix a regression in adding listener to achor element in a game'
/**
 * FRISE (FRee Interactive Story Engine)
 * A light-weight engine for writing interactive fiction and games.
 *
 * Copyright 2022-2023 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 - 2023
 */
/*
 * 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"
    }
};
/**
 * 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;
/**
 * 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.
 * @return {Promise} which will be resolved which the link is clicked.
 */
function clickProceed(message)
{
    let game_content = elt("game-content");
    game.tick++;
    game_content.innerHTML +=
        `<a id='click-proceed${game.tick}' href=''>${message}</a>`;
    return new Promise(resolve =>
        elt(`click-proceed${game.tick}`).addEventListener("click", resolve));
}
/**
 * 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);
}
/**
 * Used as part of the closure of makeGameObject so that every game
 * object has an integer id.
 * @type {number}
 */
var object_counter = 0;
/**
 * 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 {
        game_object = {};
    }
    if (dom_object.id) {
        game_object.id = dom_object.id;
    } else {
        game_object.id = "oid" + object_counter;
        object_counter++;
    }
    let has_present = false;
    let has_position = false;
    for (const child of dom_object.children) {
        let tag_name = child.tagName;
        if (tag_name == 'SCRIPT') {
            let type = child.getAttribute("type");
            if (type == "text/action") {
                tag_name = "X-ACTION";
            } else if (type == "text/default-action") {
                tag_name = "X-DEFAULT-ACTION";
            }
        }
        if (tag_name.substring(0, 2) != "X-") {
            continue;
        }
        let attribute_name = tag_name.slice(2);
        if (attribute_name) {
            attribute_name = attribute_name.toLowerCase()
            if (attribute_name == 'present') {
                has_present = true;
                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 (attribute_name == 'position') {
                    has_position = true;
                }
                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;
    }
    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)
{
    if (display_type === undefined) {
        display_type = "block";
    }
    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 hash = anchor.getAttribute("href");
        if (hash && hash[0] == "#") {
            anchor.addEventListener('click', (event) => {
                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;
                }
            });
        }
    }
}
/**
 * Used to disable any links to save or inventory pages
 * 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 disableSavesAndInventory()
{
    let save_links = sel('[href~="#saves"]');
    for (const save_link of save_links) {
        save_link.classList.add('disabled');
    }
    let inventory_links = sel('[href~="#inventory"]');
    for (const inventory_link of inventory_links) {
        inventory_link.classList.add('disabled');
    }
}
/**
 * Used to re-enable any disabled links to save or inventory pages
 * 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 disableSavesAndInventory()
 */
function enableSavesAndInventory()
{
    let save_links = sel('[href~="#saves"]');
    for (const save_link of save_links) {
        save_link.classList.remove('disabled');
    }
    let inventory_links = sel('[href~="#inventory"]');
    for (const inventory_link of inventory_links) {
        inventory_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;
}
/**
 * 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 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()
    {
        disableSavesAndInventory();
        let game_content = elt("game-content");
        game_content.innerHTML = "";
        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);
            let prepared_section = this.prepareSection(section_html);
            if (check_result) {
                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);
                }
                game_content.innerHTML += prepared_section;
            }
        }
        game_content.innerHTML += "<div class='footer-space'></div>";
        this.prepareControls();
        let anchors = sel("#game-content a, #game-content x-button");
        addListenersAnchors(anchors);
        enableSavesAndInventory();
    }
    /**
     * 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);
                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");
                    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') {
                                        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;
                                        });
                                    } else if (control_subtype == "radio") {
                                        control_field.addEventListener("click",
                                        (evt) => {
                                            if (control_field.checked) {
                                                target_object[target_field] =
                                                    control_field.value;
                                            }
                                        });
                                    } else {
                                        control_field.addEventListener("input",
                                        (evt) => {
                                            target_object[target_field] =
                                                control_field.value;
                                        });
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    }
    /**
     * 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] 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) if 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]);
            }
            staging_remainder = staging_remainder.replace(pause_pattern, "");
        }
        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 = 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;
    }
}
/**
 * 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
{
    /**
     * 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 {bool}
     */
    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;
    /**
     * 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;
    /**
     * 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.initializeObjectsLocations();
        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 = "";
            }
        }
        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.initializeObjectsLocations();
        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"></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");
        }
        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" >`
        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 .
     */
    initializeObjectsLocations()
    {
        this.objects = xtag("x-object");
        this.locations = xtag("x-location");
        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);
                }
            }
        }
    }
    /**
     * 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 = sessionStorage.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 sessionStorage 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 = sessionStorage.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;
            sessionStorage.setItem("slot" + game.id + slot_number, game_state);
            this.restoreState(save_state);
            this.evaluateAction(saves_location['default-action']);
        }
    }
    /**
     * Deletes any game data from sessionStorage 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 sessionStorage field "slot" + slot_number where it is intended
     *   (but not enforced) that the slot_number be an integer.
     */
    deleteSlot(slot_number)
    {
        slot_number = parseInt(slot_number);
        let saves_location = game.locations['saves'];
        sessionStorage.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] =  "...";
        this.evaluateAction(saves_location['default-action']);
    }
    /**
     * Deletes all game saves from sessionStorage
     */
    deleteSlotAll()
    {
        let i = 1;
        let saves_location = game.locations['saves'];
        while (saves_location.hasOwnProperty('filename' + i)) {
            this.deleteSlot(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)
    {
        if (this.has_nav_bar) {
            if (hash == "#previous") {
                this.previousHistory();
                return;
            } else if (hash == "#next") {
                this.nextHistory();
                return;
            }
        }
        let new_game_state;
        if (sessionStorage["current" + game.id]) {
            new_game_state = sessionStorage["current" + game.id];
        }
        if (!this.moveMainCharacter(hash)) {
            return;
        }
        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();
        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(";");
        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;
        }
        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();
    }
}
/**
 * Module initialization function used to set up the game object corresponding
 * to the current HTML document. 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 initGame()
{
    game = new Game();
    let use_session = false;
    if (sessionStorage["current" + game.id]) {
        use_session = true;
        game.restoreState(sessionStorage["current" + game.id]);
        game.reload = true;
    } else {
        game.base_location = game.objects['main-character'].position;
    }
    game.takeTurn("");
    game.clearHistory();
    game.initializeScreen();
}
ViewGit