Skip to content
Snippets Groups Projects
Forked from spnati / spnati
31759 commits behind the upstream repository.
  • ReformCopyright's avatar
    3a2f18bf
    Tooltip fixes · 3a2f18bf
    ReformCopyright authored
    * Refactor status icon and tooltip updating.
    * Initialize tooltips once and in one place (except for the individual selection screen, where selection cards are created dynamically) by setting tooltip attributes in index.html.
    * Change `updateIndividualSelectScreen()` to hiding and showing selection cards to avoid destroying the tooltip handlers.
    * Move `pointer-events: none` property to .opponent-image so that opponent suggestion status tooltips work.
    * Change placement of tooltips so they fit on screen.
    3a2f18bf
    History
    Tooltip fixes
    ReformCopyright authored
    * Refactor status icon and tooltip updating.
    * Initialize tooltips once and in one place (except for the individual selection screen, where selection cards are created dynamically) by setting tooltip attributes in index.html.
    * Change `updateIndividualSelectScreen()` to hiding and showing selection cards to avoid destroying the tooltip handlers.
    * Move `pointer-events: none` property to .opponent-image so that opponent suggestion status tooltips work.
    * Change placement of tooltips so they fit on screen.
spniCore.js 66.10 KiB
/********************************************************************************
 This file contains the variables and functions that forms the core of the game.
 Anything that is needed game-wide is kept here.
 ********************************************************************************/

/**********************************************************************
 * Game Wide Constants
 **********************************************************************/

/* General Constants */
var DEBUG = false;
var EPILOGUES_ENABLED = true;
var EPILOGUES_UNLOCKED = false;
var COLLECTIBLES_ENABLED = true;
var COLLECTIBLES_UNLOCKED = false;
var EPILOGUE_BADGES_ENABLED = true;
var ALT_COSTUMES_ENABLED = false;
var FORCE_ALT_COSTUME = null;
var USAGE_TRACKING = undefined;
var SENTRY_INITIALIZED = false;
var BASE_FONT_SIZE = 14;
var BASE_SCREEN_WIDTH = 100;

var USAGE_TRACKING_ENDPOINT = 'https://spnati.faraway-vision.io/usage/report';
var BUG_REPORTING_ENDPOINT = 'https://spnati.faraway-vision.io/usage/bug_report';

var CURRENT_VERSION = undefined;
var VERSION_COMMIT = undefined;

/* Game Wide Constants */
var HUMAN_PLAYER = 0;

/* Directory Constants */
var IMG = 'img/';

var backgroundImage;

/*var OPP = 'opponents/';
#The "OPP" folder abbreviation was used to slightly shorten a few lines in spniSelect that looked for opponents in the opponents folder.
#Now that opponents can be specified in any folder, this is no longer required.*/

/* Gender Images */
var MALE_SYMBOL = IMG + 'male.png';
var FEMALE_SYMBOL = IMG + 'female.png';

var includedOpponentStatuses = {};
var alternateCostumeSets = {};

var versionInfo = null;

/* game table */
var tableOpacity = 1;
$gameTable = $('#game-table');

/* useful variables */
var BLANK_PLAYER_IMAGE = "opponents/blank.png";

/* player array */
var players = Array(5);
var humanPlayer;

/* Current timeout ID, so we can cancel it when restarting the game in order to avoid trouble. */
var timeoutID;

/**********************************************************************
 * Game Wide Global Variables
 **********************************************************************/

var table = new Table();
var jsErrors = [];
var sessionID = '';
var gameID = '';
var generalCollectibles = [];
var codeImportEnabled = false;

/**********************************************************************
 * Screens & Modals
 **********************************************************************/

/* Screens */
$warningScreen = $('#warning-screen');
$titleScreen = $('#title-screen');
$selectScreen = $('#main-select-screen');
$individualSelectScreen = $('#individual-select-screen');
$groupSelectScreen = $('#group-select-screen');
$gameScreen = $('#game-screen');
$epilogueScreen = $('#epilogue-screen');
$galleryScreen = $('#gallery-screen');

var allScreens = [$warningScreen, $titleScreen, $selectScreen, $individualSelectScreen, $groupSelectScreen, $gameScreen, $epilogueScreen, $galleryScreen];

/* Modals */
$helpModal = $('#help-modal');
$creditModal = $('#credit-modal');
$versionModal = $('#version-modal');
$bugReportModal = $('#bug-report-modal');
$usageTrackingModal = $('#usage-reporting-modal');
$playerTagsModal = $('#player-tags-modal');
$collectibleInfoModal = $('#collectibles-info-modal');
$ioModal = $('#io-modal');

/* Screen State */
$previousScreen = null;

/* CSS rules for arrow offsets */
var bubbleArrowOffsetRules;

/********************************************************************************
 * Game Wide Utility Functions
 ********************************************************************************/

function getReportedOrigin () {
    var origin = window.location.origin;

    if (origin.toLowerCase().startsWith('file:')) {
        return '<local filesystem origin>';
    } else {
        return origin;
    }
}

/* Gathers most of the generic information for an error report. */
function compileBaseErrorReport(userDesc, bugType) {
    var tableReports = [];
    for (let i=1;i<players.length;i++) {
        if (players[i]) {
            playerData = {
                'id': players[i].id,
                'slot': i,
                'stage': players[i].stage,
                'timeInStage': players[i].timeInStage,
                'markers': players[i].markers,
                'oneShotCases': players[i].oneShotCases,
                'oneShotStates': players[i].oneShotStates,
            }

            if (players[i].chosenState) {
                playerData.currentLine    = players[i].chosenState.rawDialogue;
                playerData.currentImage   = players[i].folder + players[i].chosenState.image;
            }

            tableReports[i-1] = playerData;
        } else {
            tableReports[i-1] = null;
        }
    }

    var circumstances = {
        'userAgent': navigator.userAgent,
        'origin': getReportedOrigin(),
        'currentRound': currentRound,
        'currentTurn': currentTurn,
        'previousLoser': previousLoser,
        'recentLoser': recentLoser,
        'gameOver': gameOver,
        'visibleScreens': [],
        'rollback': inRollback()
    }

    if (gamePhase) {
        if (inRollback()) {
            circumstances.gamePhase = rolledBackGamePhase[0];
        } else {
            circumstances.gamePhase = gamePhase[0];
        }
    }

    for (let i=0;i<allScreens.length;i++) {
        if (allScreens[i].css('display') !== 'none') {
            circumstances.visibleScreens.push(allScreens[i].attr('id'));
        }
    }

    var bugCharacter = null;
    if (bugType.startsWith('character')) {
        bugCharacter = bugType.split(':', 2)[1];
        bugType = 'character';
    }

    return {
        'date': (new Date()).toISOString(),
        'commit': VERSION_COMMIT,
        'session': sessionID,
        'game': gameID,
        'type': bugType,
        'character': bugCharacter,
        'description': userDesc,
        'circumstances': circumstances,
        'table': tableReports,
        'player': {
            'gender': humanPlayer.gender,
            'size': humanPlayer.size,
        },
        'jsErrors': jsErrors,
    };
}

window.addEventListener('error', function (ev) {
    jsErrors.push({
        'date': (new Date()).toISOString(),
        'type': ev.error.name,
        'message': ev.message,
        'filename': ev.filename,
        'lineno': ev.lineno,
        'stack': ev.error.stack
    });

    if (USAGE_TRACKING) {
        var report = compileBaseErrorReport('Automatically generated after Javascript error.', 'auto');
        $.ajax({
            url: BUG_REPORTING_ENDPOINT,
            method: 'POST',
            data: JSON.stringify(report),
            contentType: 'application/json',
            error: function (jqXHR, status, err) {
                console.error("Could not send bug report - error "+status+": "+err);
            },
        });
    }
});

/* Fetch a possibly compressed file.
 * Attempts to fetch a compressed version of the file first,
 * then fetches the uncompressed version of the file if that isn't found.
 */
function fetchCompressedURL(baseUrl) {
    /*
     * The usual Jquery AJAX request function doesn't play nice with
     * the binary-encoded data we'll get here, so we do the XHR manually.
     * (I would use fetch() were it not for compatibility issues.)
     */
    var req = new XMLHttpRequest();
    req.open('GET', baseUrl+'.gz', true);
    req.responseType = 'arraybuffer';
    var deferred = $.Deferred();

    req.onload = function(ev) {
        if (req.status < 400 && req.response) {
            var data = new Uint8Array(req.response);
            var decompressed = pako.inflate(data, { to: 'string' });
            deferred.resolve(decompressed);
        } else if (req.status === 404) {
            $.ajax({
                type: "GET",
        		url: baseUrl,
        		dataType: "text",
                success: deferred.resolve.bind(deferred),
                error: deferred.reject.bind(deferred),
            });
        } else {
            errorCb();
        }
    }

    req.onerror = function(err) {
        $.ajax({
            type: "GET",
            url: baseUrl,
            dataType: "text",
            success: deferred.resolve.bind(deferred),
            error: deferred.reject.bind(deferred),
        });
    }

    req.send(null);
    return deferred.promise();
}


/**********************************************************************
 *****                Player Object Specification                 *****
 **********************************************************************/

 /************************************************************
  * Creates and returns a new player object based on the
  * supplied information.
  *
  * folder (string), the path to their folder
  * first (string), their first name.
  * last (string), their last name.
  * labels (string or XML element), what's shown on screen and what other players refer to them as.
  *   Can vary by stage.
  * size (string): Their level of endowment
  * intelligence (string or XML element), the name of their AI algorithm.
  *   Can vary by stage.
  * gender (constant), their gender.
  * clothing (array of Clothing objects), their clothing.
  * stamina (integer), time until forfeit is finished (initial timer value).
  * state (array of PlayerState objects), their sequential states.
  * xml (jQuery object), the player's loaded behaviour.xml file.
  * metaXml (jQuery object), the player's loaded meta.xml file.
  ************************************************************/

function Player (id) {
    this.id = id;
    this.folder = 'opponents/'+id+'/';
    this.base_folder = 'opponents/'+id+'/';
    this.first = '';
    this.last = '';
    this.labels = undefined;
    this.folders = undefined;
    this.size = eSize.MEDIUM;
    this.intelligence = eIntelligence.AVERAGE;
    this.gender = eGender.MALE;
    this.stamina = 20;
    this.scale = undefined;
    this.tags = this.baseTags = [];
    this.xml = null;
    this.metaXml = null;

    this.resetState();
}

/*******************************************************************
 * Sets initial values of state variables used by targetStatus,
 * targetStartingLayers etc. adccording to wardrobe.
 *******************************************************************/
Player.prototype.initClothingStatus = function () {
	this.startingLayers = this.clothing.length;
	this.exposed = { upper: true, lower: true };
	for (var position in this.exposed) {
		if (this.clothing.some(function(c) {
			return (c.type == IMPORTANT_ARTICLE || c.type == MAJOR_ARTICLE)
				&& (c.position == position || c.position == FULL_ARTICLE);
		})) {
			this.exposed[position] = false;
		};
	}
	this.mostlyClothed = this.decent = !(this.exposed.upper || this.exposed.lower)
		&& this.clothing.some(function(c) {
			return c.type == MAJOR_ARTICLE
				&& [UPPER_ARTICLE, LOWER_ARTICLE, FULL_ARTICLE].indexOf(c.position) >= 0;
		});
}

/*******************************************************************
 * (Re)Initialize the player properties that change during a game
 *******************************************************************/
Player.prototype.resetState = function () {
    this.out = this.finished = false;
    this.outOrder = undefined;
    this.biggestLead = 0;
	this.forfeit = "";
	this.stage = this.current = this.consecutiveLosses = 0;
	this.timeInStage = -1;
	this.markers = {};
	this.oneShotCases = {};
	this.oneShotStates = {};
	if (this.xml !== null) {
        /* Load in the legacy "start" lines, and also
         * initialize player.chosenState to the first listed line.
         * This may be overridden by later updateBehaviour calls if
         * the player has (new-style) selected or game start case lines.
         */
		var allStates = [];

        /* Initialize reaction handling state. */
        this.volatileMatches = [];
        this.bestVolatileMatch = null;
        this.currentTarget = null;
        this.currentPriority = -1;
        this.stateLockCount = 0;
        this.stateCommitted = false;

        this.xml.children('start').children('state').each(function () {
            allStates.push(new State($(this)));
        });

        this.allStates = allStates;
		this.chosenState = this.allStates[0];

        if (!this.chosenState) {
            /* If the opponent does not have legacy start lines then select
             * a new-style selected line immediately.
             * Prevents a crash triggered by selected, unselecting, and re-selecting
             * an opponent with no legacy starting lines.
             */
            this.updateBehaviour(SELECTED);
        }

        this.commitBehaviourUpdate();

        var appearance = this.default_costume;
        if (ALT_COSTUMES_ENABLED && this.alt_costume) {
            appearance = this.alt_costume;
        }

        this.labels = appearance.labels;
        this.folders = appearance.folders;
        this.baseTags = appearance.tags.slice();
        this.labelOverridden = false;

		/* Load the player's wardrobe. */

    	/* Find and grab the wardrobe tag */
    	$wardrobe = appearance.wardrobe;

    	/* find and create all of their clothing */
        var clothingArr = [];
    	$wardrobe.find('clothing').each(function () {
            var generic = $(this).attr('generic');
            var name = $(this).attr('name') || $(this).attr('lowercase');
            var type = $(this).attr('type');
            var position = $(this).attr('position');
            var plural = ['true', 'yes'].indexOf($(this).attr('plural')) >= 0;

            var newClothing = new Clothing(name, generic, type, position, null, plural, 0);

            clothingArr.push(newClothing);
    	});
        
        this.poses = appearance.poses;

        this.clothing = clothingArr;
		this.initClothingStatus();
	}

	this.stageChangeUpdate();
}

Player.prototype.getIntelligence = function () {
    return this.intelligence; // Opponent uses getByStage()
};

/* These shouldn't do anything for the human player, but exist as empty functions
   to make it easier to iterate over the entire players[] array. */
Player.prototype.updateLabel = function () { }
Player.prototype.updateFolder = function () { }
Player.prototype.updateBehaviour = function() { }

/* Compute the Player's tags list from their baseTags list. */
Player.prototype.updateTags = function () {
    var tags = [this.id];
    var stage = this.stage || 0;
    
    if (this.alt_costume && this.alt_costume.id) {
        tags.push(this.alt_costume.id);
    }
    
    this.baseTags.forEach(function (tag_desc) {
        if (typeof(tag_desc) === 'string') {
            tags.push(tag_desc);
            return;
        }
        
        if (!tag_desc.tag) return;
        
        var tag = tag_desc.tag;
        var from = parseInt(tag_desc.from, 10);
        var to = parseInt(tag_desc.to, 10);
        
        if (isNaN(to))   to = Number.POSITIVE_INFINITY;
        if (isNaN(from)) from = 0;
        
        if (stage >= from && stage <= to) {
            tags.push(tag);
        }
    });
    
    this.tags = expandTagsList(tags);
}

Player.prototype.stageChangeUpdate = function () {
    this.updateLabel();
    this.updateFolder();
    this.updateTags();
}

Player.prototype.addTag = function(tag) {
    this.baseTags.push(canonicalizeTag(tag));
}

Player.prototype.removeTag = function(tag) {
    tag = canonicalizeTag(tag);
    
    this.baseTags = this.baseTags.filter(function (t) {
        if (typeof(t) === 'string') { return t !== tag };
        if (!t.tag) return false;
        return t.tag !== tag;
    });
}

Player.prototype.hasTag = function(tag) {
    return tag && this.tags && this.tags.indexOf(canonicalizeTag(tag)) >= 0;
};

Player.prototype.countLayers = function() {
	return this.clothing.length;
};

Player.prototype.checkStatus = function(status) {
	if (status.substr(0, 4) == "not_") {
		return !this.checkStatus(status.substr(4));
	}
	switch (status.trim()) {
	case STATUS_LOST_SOME:
		return this.stage > 0;
	case STATUS_MOSTLY_CLOTHED:
		return this.mostlyClothed;
	case STATUS_DECENT:
		return this.decent;
	case STATUS_EXPOSED_TOP:
		return this.exposed.upper;
	case STATUS_EXPOSED_BOTTOM:
		return this.exposed.lower;
	case STATUS_EXPOSED:
		return this.exposed.upper || this.exposed.lower;
	case STATUS_EXPOSED_TOP_ONLY:
		return this.exposed.upper && !this.exposed.lower;
	case STATUS_EXPOSED_BOTTOM_ONLY:
		return !this.exposed.upper && this.exposed.lower;
	case STATUS_NAKED:
		return this.exposed.upper && this.exposed.lower;
	case STATUS_ALIVE:
		return !this.out;
	case STATUS_LOST_ALL:
		return this.clothing.length == 0;
	case STATUS_MASTURBATING:
		return this.out && !this.finished;
	case STATUS_FINISHED:
		return this.finished;
	}
}

/*****************************************************************************
 * Subclass of Player for AI-controlled players.
 ****************************************************************************/
function Opponent (id, $metaXml, status, releaseNumber) {
    this.id = id;
    this.folder = 'opponents/'+id+'/';
    this.base_folder = 'opponents/'+id+'/';
    this.metaXml = $metaXml;

    this.status = status;
    this.first = $metaXml.find('first').text();
    this.last = $metaXml.find('last').text();
    this.label = $metaXml.find('label').text();
    this.image = $metaXml.find('pic').text();
    this.gender = $metaXml.find('gender').text();
    this.height = $metaXml.find('height').text();
    this.source = $metaXml.find('from').text();
    this.artist = $metaXml.find('artist').text();
    this.writer = $metaXml.find('writer').text();
    this.description = fixupDialogue($metaXml.find('description').html());
    this.endings = $metaXml.find('epilogue');
    this.ending = this.endings.length > 0 || $metaXml.find('has_ending').text() === "true";
    this.has_collectibles = $metaXml.find('has_collectibles').text() === "true";
    this.collectibles = null;
    this.layers = parseInt($metaXml.find('layers').text(), 10);
    this.scale = Number($metaXml.find('scale').text()) || 100.0;
    this.release = parseInt(releaseNumber, 10) || Number.POSITIVE_INFINITY;
    this.uniqueLineCount = parseInt($metaXml.find('lines').text(), 10) || undefined;
    this.posesImageCount = parseInt($metaXml.find('poses').text(), 10) || undefined;
    this.z_index = parseInt($metaXml.find('z-index').text(), 10) || 0;
    this.dialogue_layering = $metaXml.find('dialogue-layer').text();
    
    if (['over', 'under'].indexOf(this.dialogue_layering) < 0) {
        this.dialogue_layering = 'under';
    }
    
    this.selected_costume = null;
    this.alt_costume = null;
    this.default_costume = null;
    this.poses = {};
    this.labelOverridden = false;
    this.pendingCollectiblePopup = null;
    
    /* baseTags stores tags that will be later used in resetState to build the
     * opponent's true tags list. It does not store implied tags.
     *
     * The tags list stores the fully-expanded list of tags for the opponent,
     * including implied tags.
     */
    this.baseTags = $metaXml.find('tags').children().map(function() { return canonicalizeTag($(this).text()); }).get();
    this.updateTags();
    
    /* Attempt to preload this opponent's picture for selection. */
    new Image().src = 'opponents/'+id+'/'+this.image;

    this.alternate_costumes = [];
    this.selection_image = this.folder + this.image;
    
    $metaXml.find('alternates').find('costume').each(function (i, elem) {
        var set = $(elem).attr('set') || 'offline';
        
        if (alternateCostumeSets['all'] || alternateCostumeSets[set]) {
            var costume_descriptor = {
                'folder': $(elem).attr('folder'),
                'label': $(elem).text(),
                'image': $(elem).attr('img'),
                'set': set
            };
            
            if (set === FORCE_ALT_COSTUME) {
                this.selection_image = costume_descriptor['folder'] + costume_descriptor['image'];
                this.selectAlternateCostume(costume_descriptor);
            }
            
            this.alternate_costumes.push(costume_descriptor);
        }
    }.bind(this)).get();
}

Opponent.prototype = Object.create(Player.prototype);
Opponent.prototype.constructor = Opponent;

Opponent.prototype.clone = function() {
	var clone = Object.create(Opponent.prototype);
	/* This should be deep enough for our purposes. */
	for (var prop in this) {
		if (this[prop] instanceof Array) {
			clone[prop] = this[prop].slice();
		} else {
			clone[prop] = this[prop];
		}
	}
	return clone;
}

Opponent.prototype.isLoaded = function() {
	return this.xml != undefined;
}

Opponent.prototype.onSelected = function(individual) {
    this.resetState();
    console.log(this.slot+": ");
    console.log(this);
    
    // check for duplicate <link> elements:
    if (this.stylesheet) {
        if ($('link[href=\"'+this.stylesheet+'\"]').length === 0) {
            console.log("Loading stylesheet: "+this.stylesheet);
            
            var link_elem = $('<link />', {
                'rel': 'stylesheet',
                'type': 'text/css',
                'href': this.stylesheet
            });
            
            $('head').append(link_elem);
        }
    }
    
    this.preloadStageImages(-1);
    if (individual) {
        updateAllBehaviours(this.slot, SELECTED, [[OPPONENT_SELECTED]]);
    } else {
        this.updateBehaviour(SELECTED);
        this.commitBehaviourUpdate();
    }

    updateSelectionVisuals();
}

Opponent.prototype.updateLabel = function () {
    if (this.labels && !this.labelOverridden) this.label = this.getByStage(this.labels);
}

Opponent.prototype.updateFolder = function () {
    if (this.folders) this.folder = this.getByStage(this.folders);
}

Opponent.prototype.getByStage = function (arr, stage) {
    if (typeof(arr) === "string") {
        return arr;
    }
    if (stage === undefined) stage = this.stage;
    var bestFitStage = -1;
    var bestFit = null;
    for (var i = 0; i < arr.length; i++) {
        var startStage = arr[i].getAttribute('stage');
        startStage = parseInt(startStage, 10) || 0;
        if (startStage > bestFitStage && startStage <= stage) {
            bestFit = $(arr[i]).text();
            bestFitStage = startStage;
        }
    }
    return bestFit;
};

Opponent.prototype.selectAlternateCostume = function (costumeDesc) {
    if (!costumeDesc) {
        this.selected_costume = null;
        this.selection_image = this.base_folder + this.image;
    } else {
        this.selected_costume = costumeDesc.folder;
        this.selection_image = costumeDesc.folder + costumeDesc.image;
    }
};

Opponent.prototype.getIntelligence = function () {
    return this.getByStage(this.intelligence) || eIntelligence.AVERAGE;
};

Opponent.prototype.loadAlternateCostume = function (individual) {
    if (this.alt_costume) {
        if (this.alt_costume.folder != this.selected_costume) {
            this.unloadAlternateCostume();
        } else {
            return;
        }
    }
    console.log("Loading alternate costume: "+this.selected_costume);
    $.ajax({
        type: "GET",
        url: this.selected_costume+'costume.xml',
        dataType: "text",
        success: function (xml) {
            var $xml = $(xml);

            this.alt_costume = {
                id: $xml.find('id').text(),
                labels: $xml.find('label'),
                tags: [],
                folder: this.selected_costume,
                folders: $xml.find('folder'),
                wardrobe: $xml.find('wardrobe')
            };
            
            var poses = $xml.find('poses');
            var poseDefs = {};
            $(poses).find('pose').each(function (i, elem) {
                var def = new PoseDefinition($(elem), this);
                poseDefs[def.id] = def;
            }.bind(this));
            
            this.alt_costume.poses = poseDefs;

            var costumeTags = this.default_costume.tags.slice();
            var tagMods = $xml.find('tags');
            if (tagMods) {
                var newTags = [];
                tagMods.find('tag').each(function (idx, elem) {
                    var $elem = $(elem);
                    var tag = canonicalizeTag(tag);
                    var removed = $elem.attr('remove') || '';
                    var fromStage = $elem.attr('from');
                    var toStage = $elem.attr('to');

                    // Remove previous declarations for this tag
                    costumeTags = costumeTags.filter(function (t) { return t.tag !== tag; });

                    if (removed.toLowerCase() !== 'true') {
                        newTags.push({'tag': tag, 'from': fromStage, 'to': toStage});
                    }
                });
                
                Array.prototype.push.apply(costumeTags, newTags);
            }

            this.alt_costume.tags = costumeTags;

            this.onSelected(individual);
        }.bind(this),
        error: function () {
            console.error("Failed to load alternate costume: "+this.selected_costume);
        },
    })
}

Opponent.prototype.unloadAlternateCostume = function () {
    if (!this.alt_costume) {
        return;
    }
    
    this.alt_costume = null;
    this.selectAlternateCostume(null);
    this.resetState();
}

Opponent.prototype.loadCollectibles = function (onLoaded, onError) {
    if (!this.has_collectibles) return;
    if (this.collectibles !== null) return;
    
    $.ajax({
		type: "GET",
		url: this.folder + 'collectibles.xml',
		dataType: "text",
		success: function(xml) {
			var collectiblesArray = [];
			$(xml).find('collectible').each(function (idx, elem) {
				collectiblesArray.push(new Collectible($(elem), this));
			}.bind(this));
			
			this.collectibles = collectiblesArray;
            
            if (onLoaded) onLoaded(this);
		}.bind(this),
        error: function (jqXHR, status, err) {
            console.error("Error loading collectibles for "+this.id+": "+status+" - "+err);
            if (onError) onError(this, status, err);
        }.bind(this)
	});
}

/* Called prior to removing a character from the table. */
Opponent.prototype.unloadOpponent = function () {
    if (SENTRY_INITIALIZED) {
        Sentry.addBreadcrumb({
            category: 'select',
            message: 'Unloading opponent ' + this.id,
            level: 'info'
        });
    }

    if (this.stylesheet) {
        /* Remove the <link> to this opponent's stylesheet. */
        $('link[href=\"'+this.stylesheet+'\"]').remove();
    }
}

/************************************************************
 * Loads and parses the start of the behaviour XML file of the
 * given opponent.
 *
 * The onLoadFinished parameter must be a function capable of
 * receiving a new player object and a slot number.
 ************************************************************/
Opponent.prototype.loadBehaviour = function (slot, individual) {
    this.slot = slot;
    if (this.isLoaded()) {
        if (this.selected_costume) {
            this.loadAlternateCostume();
        } else {
            this.unloadAlternateCostume();
            this.onSelected(individual);
        }
        return;
    }

    fetchCompressedURL('opponents/' + this.id + "/behaviour.xml")
		/* Success callback.
         * 'this' is bound to the Opponent object.
         */
		.then(function(xml) {
            var $xml = $(xml);

            if (this.has_collectibles) {
                this.loadCollectibles();
            }

            this.xml = $xml;
            this.size = $xml.find('size').text();
            this.stamina = Number($xml.find('timer').text());
            this.intelligence = $xml.find('intelligence');

            this.stylesheet = null;
            
            var stylesheet = $xml.find('stylesheet').text();
            if (stylesheet) {
                var m = stylesheet.match(/[a-zA-Z0-9()~!*:@,;\-.\/]+\.css/i);
                if (m) {
                    this.stylesheet = 'opponents/'+this.id+'/'+m[0];
                }
            }

            /* The gender listed in meta.xml and behaviour.xml might differ
             * (for example with gender-revealing characters)
             * So assume behaviour.xml holds the 'definitive' starting gender
             * for the character.
             */
            var startGender = $xml.find('gender').text();
            if (startGender) {
                this.gender = startGender;    
            }

            this.default_costume = {
                id: null,
                labels: $xml.find('label'),
                tags: null,
                folders: this.folder,
                wardrobe: $xml.find('wardrobe')
            };
            
            var poses = $xml.find('poses');
            var poseDefs = {};
            $(poses).find('pose').each(function (i, elem) {
                var def = new PoseDefinition($(elem), this);
                poseDefs[def.id] = def;
            }.bind(this));
            
            this.default_costume.poses = poseDefs;

            var tags = $xml.find('tags');
            var tagsArray = [];
            if (typeof tags !== typeof undefined && tags !== false) {
                tagsArray = $(tags).find('tag').map(function () {
                    return {
                        'tag': canonicalizeTag($(this).text()),
                        'from': $(this).attr('from'),
                        'to': $(this).attr('to'),
                    }
                }).get();
            }

            this.default_costume.tags = tagsArray;

            var targetedLines = {};

            $xml.find('>behaviour').find('case[target], case[alsoPlaying], case[filter], case:has(condition[character]), case:has(condition[filter])').each(function() {
                var $case = $(this);
                $case.children('condition[character]').map(function() { return $(this).attr('character'); }).get()
                    .concat($case.children('condition[filter]').map(function() { return $(this).attr('filter'); }).get())
                    .concat([$case.attr('target'), $case.attr('alsoPlaying'), $case.attr('filter')]).forEach(function(id) {
                    if (id) {
                        if (!(id in targetedLines)) { targetedLines[id] = { count: 0, seen: new Set() }; }
                        $(this).children('state').each(function() {
                            targetedLines[id].seen.add(this.textContent);
                        });
                    }
                }, this);
            });

            this.targetedLines = targetedLines;

            var nicknames = {};
            $xml.find('nicknames>nickname').each(function() {
                if ($(this).attr('for') in nicknames) {
                    nicknames[$(this).attr('for')].push($(this).text());
                } else {
                    nicknames[$(this).attr('for')] = [ $(this).text() ];
                }
            });
            this.nicknames = nicknames;
            
            if (this.selected_costume) {
                return this.loadAlternateCostume();
            }
            
            return this.onSelected(individual);
		}.bind(this))
		/* Error callback. */
        .fail(function(err) {
            console.log("Failed reading \""+this.id+"\" behaviour.xml");
            delete players[this.slot];
        }.bind(this));
}

Player.prototype.getImagesForStage = function (stage) {
    if(!this.xml) return [];

    var imageSet = {};
    var folder = this.folders ? this.getByStage(this.folders, stage) : this.folder;
    var advPoses = this.poses;
    var selector = (stage == -1 ? 'start, stage[id=1] case[tag=game_start]'
                    : 'stage[id='+stage+'] case');
                    
    this.xml.find(selector).each(function () {
        var target = $(this).attr('target'), alsoPlaying = $(this).attr('alsoPlaying'),
            filter = canonicalizeTag($(this).attr('filter'));
        // Skip cases requiring a character that isn't present
        if ((target === undefined || players.some(function(p) { return p.id === target; }))
            && (alsoPlaying === undefined || players.some(function(p) { return p.id === alsoPlaying; }))
            && (filter === undefined || players.some(function(p) { return p.hasTag(filter); })))
        {
            $(this).children('state').each(function (i, e) {
                var poseName = $(e).attr('img');
                if (!poseName) return;
                
                if (poseName.startsWith('custom:')) {
                    var key = poseName.split(':', 2)[1];
                    var pose = advPoses[key];
                    if (pose) pose.getUsedImages().forEach(function (img) {
                        imageSet[img] = true;
                    });
                } else {
                    imageSet[folder+poseName] = true;
                }
            });
        }
    });
    
    return Object.keys(imageSet);
};

Player.prototype.preloadStageImages = function (stage) {
    this.getImagesForStage(stage)
        .forEach(function(fn) { new Image().src = fn; }, this );
};

/**********************************************************************
 *****              Overarching Game Flow Functions               *****
 **********************************************************************/

/************************************************************
 * Loads the initial content of the game.
 ************************************************************/
function initialSetup () {
    /* start by creating the human player object */
    players[HUMAN_PLAYER] = humanPlayer = new Player('human'); //createNewPlayer("human", "", "", "", eGender.MALE, eSize.MEDIUM, eIntelligence.AVERAGE, 20, undefined, [], null);
    humanPlayer.slot = HUMAN_PLAYER;

    /* Generate a random session ID. */
    sessionID = generateRandomID();

	/* enable table opacity */
	tableOpacity = 1;
	$gameTable.css({opacity:1});

    /* load the all content */
    loadTitleScreen();
    selectTitleCandy();
    loadVersionInfo();
    loadGeneralCollectibles();
    
	/* Make sure that the config file is loaded before processing the
     *  opponent list, so that includedOpponentStatuses is populated.
     *
     * Also ensure that the config file is loaded before initializing Sentry,
     * which requires the commit SHA.
     */

    save.load();
    
    loadConfigFile().always(loadSelectScreen, function() {
        if (USAGE_TRACKING && !SENTRY_INITIALIZED) sentryInit();
    });

    updateTitleGender();

    if (SENTRY_INITIALIZED) Sentry.setTag("screen", "warning");

	/* show the title screen */
	$warningScreen.show();
	$('#warning-start-button').focus();
    autoResizeFont();

    /* Construct a CSS rule for every combination of arrow direction, screen, and pseudo-element */
    bubbleArrowOffsetRules = [];
    for (var i = 1; i <= 4; i++) {
        var pair = [];
        [["up", "down"], ["left", "right"]].forEach(function(p) {
            var index = document.styleSheets[2].cssRules.length;
            var rule = p.map(function(d) {
                return ["select", "game"].map(function(s) {
                    return '#'+s+'-bubble-'+i+'.arrow-'+d+'::before';
                }).join(', ');
            }).join(', ') + ' {}';
            document.styleSheets[2].insertRule(rule, index);
            pair.push(document.styleSheets[2].cssRules[index]);
        });
        bubbleArrowOffsetRules.push(pair);
    }
    $(document).keydown(function(ev) {
        if (ev.keyCode == 9) {
            $("body").addClass('focus-indicators-enabled');
        }
    });
    $(document).keyup(groupSelectKeyToggle);
    $(document).mousedown(function(ev) {
        $("body").removeClass('focus-indicators-enabled');
    });

    $('[data-toggle="tooltip"]').tooltip({ delay: { show: 200 } });
}

function loadVersionInfo () {
    $('.substitute-version').text('Unknown Version');
    
    return $.ajax({
        type: "GET",
		url: "version-info.xml",
		dataType: "text",
		success: function(xml) {
            versionInfo = $(xml);
            CURRENT_VERSION = versionInfo.find('current').attr('version');

            if (SENTRY_INITIALIZED) Sentry.setTag("game_version", CURRENT_VERSION);
            
            $('.substitute-version').text('v'+CURRENT_VERSION);
            console.log("Running SPNATI version "+CURRENT_VERSION);
            
            version_ts = versionInfo.find('changelog > version[number=\"'+CURRENT_VERSION+'\"]').attr('timestamp');        
            
            version_ts = parseInt(version_ts, 10);
            now = Date.now();
            
            elapsed_time = now - version_ts;
            
            /* Format last update time */
            last_update_string = '';
            if (elapsed_time < 5 * 60 * 1000) {
                // <5 minutes ago - display 'just now'
                last_update_string = 'just now';
            } else if (elapsed_time < 60 * 60 * 1000) {
                // < 1 hour ago - display minutes since last update
                last_update_string = Math.floor(elapsed_time / (60 * 1000))+' minutes ago';
            } else if (elapsed_time < 24 * 60 * 60 * 1000) {
                // < 1 day ago - display hours since last update
                var n_hours = Math.floor(elapsed_time / (60 * 60 * 1000));
                last_update_string = n_hours + (n_hours === 1 ? ' hour ago' : ' hours ago');
            } else {
                // otherwise just display days since last update
                var n_days = Math.floor(elapsed_time / (24 * 60 * 60 * 1000));
                last_update_string =  n_days + (n_days === 1 ? ' day ago' : ' days ago');
            }
            
            $('.substitute-version-time').text('(updated '+last_update_string+')');

            $('.version-button').click(showVersionModal);
        }
    });
}


function loadConfigFile () {
	return $.ajax({
        type: "GET",
		url: "config.xml",
		dataType: "text",
		success: function(xml) {
			var _epilogues = $(xml).find('epilogues').text();
            if(_epilogues.toLowerCase() === 'false') {
                EPILOGUES_ENABLED = false;
                console.log("Epilogues are disabled.");
                $("#title-gallery-edge").hide();
            } else {
                console.log("Epilogues are enabled.");
                EPILOGUES_ENABLED = true;
            }

            var _epilogues_unlocked = $(xml).find('epilogues-unlocked').text().trim();
            if (_epilogues_unlocked.toLowerCase() === 'true') {
                EPILOGUES_UNLOCKED = true;
                console.error('All epilogues unlocked in config file. You better be using this for development only and not cheating!');
            } else {
                EPILOGUES_UNLOCKED = false;
            }

            var _epilogue_badges = $(xml).find('epilogue_badges').text();
            if(_epilogue_badges.toLowerCase() === 'false') {
                EPILOGUE_BADGES_ENABLED = false;
                console.log("Epilogue badges are disabled.");
            } else {
                console.log("Epilogue badges are enabled.");
                EPILOGUE_BADGES_ENABLED = true;
            }

			var _debug = $(xml).find('debug').text();

            if (_debug === "true") {
                DEBUG = true;
                console.log("Debugging is enabled");
            }
            else {
                DEBUG = false;
                console.log("Debugging is disabled");
            }

            var _game_commit = $(xml).find('commit').text();
            if (_game_commit) {
                VERSION_COMMIT = _game_commit;
                console.log("Running SPNATI commit "+VERSION_COMMIT+'.');
            } else {
                console.log("Could not find currently deployed Git commit!");
            }

            var _alts = $(xml).find('alternate-costumes').text();

            if(_alts === "true") {
                ALT_COSTUMES_ENABLED = true;
                console.log("Alternate costumes enabled");
                
                FORCE_ALT_COSTUME = $(xml).find('force-alternate-costume').text();
                if (FORCE_ALT_COSTUME) {
                    console.log("Forcing alternate costume set: "+FORCE_ALT_COSTUME);
                    alternateCostumeSets[FORCE_ALT_COSTUME] = true;
                } else {
                    $(xml).find('alternate-costume-sets').each(function () {
                        var set = $(this).text();
                        alternateCostumeSets[set] = true;
                        if (set === 'all') {
                            console.log("Including all alternate costume sets");
                        } else {
                            console.log("Including alternate costume set: "+set);
                        }
                    });
                }
            } else {
                ALT_COSTUMES_ENABLED = false;
                console.log("Alternate costumes disabled");
            }
            
            if (DEBUG) {
                COLLECTIBLES_ENABLED = false;
                COLLECTIBLES_UNLOCKED = false;
                
                if ($(xml).find('collectibles').text() === 'true') {
                    COLLECTIBLES_ENABLED = true;
                    console.log("Collectibles enabled");
                    
                    if ($(xml).find('collectibles-unlocked').text() === 'true') {
                        COLLECTIBLES_UNLOCKED = true;
                        console.log("All collectibles force-unlocked");
                    }
                } else {
                    console.log("Collectibles disabled");
                }
            } else {
                COLLECTIBLES_ENABLED = false;
                COLLECTIBLES_UNLOCKED = false;
                console.log("Debug mode disabled - collectibles disabled");
            }

			$(xml).find('include-status').each(function() {
				includedOpponentStatuses[$(this).text()] = true;
				console.log("Including", $(this).text(), "opponents");
			});
		}
	});
}

function loadGeneralCollectibles () {
    $.ajax({
		type: "GET",
		url: 'opponents/general_collectibles.xml',
		dataType: "text",
		success: function(xml) {
			var collectiblesArray = [];
			$(xml).find('collectible').each(function (idx, elem) {
				generalCollectibles.push(new Collectible($(elem), undefined));
			});
		},
        error: function (jqXHR, status, err) {
            console.error("Error loading general collectibles: "+status+" - "+err);
        }
	});
}


function enterTitleScreen() {
    $warningScreen.hide();
    $titleScreen.show();
    $('#title-start-button').focus();
    if (SENTRY_INITIALIZED) Sentry.setTag("screen", "title");
}

/************************************************************
 * Transitions between two screens.
 ************************************************************/
function screenTransition (first, second) {
	first.hide();
	second.show();
    autoResizeFont();
}

/************************************************************
 * Switches to the next screen based on the screen provided.
 ************************************************************/
function advanceToNextScreen (screen) {
    if (screen == $titleScreen) {
        /* advance to the select screen */
		screenTransition($titleScreen, $selectScreen);
    } else if (screen == $selectScreen) {
        /* advance to the main game screen */
        $selectScreen.hide();
		loadGameScreen();
        $gameScreen.show();
        $mainButton.focus();
    }
}

/************************************************************
 * Switches to the last screen based on the screen provided.
 ************************************************************/
function returnToPreviousScreen (screen) {
    if (screen == $selectScreen) {
        /* return to the title screen */
        $selectScreen.hide();
        $titleScreen.show();
    }
}

/************************************************************
 * Resets the game state so that the game can be restarted.
 ************************************************************/
function resetPlayers () {
    players.forEach(function(p) {
        p.resetState();
    });
    updateAllBehaviours(null, null, SELECTED);
}

/************************************************************
 * Restarts the game.
 ************************************************************/
function restartGame () {
    if (SENTRY_INITIALIZED) {
        Sentry.addBreadcrumb({
            category: 'ui',
            message: 'Returning to title screen.',
            level: 'info'
        });

        Sentry.setTag("screen", "title");
        Sentry.setTag("epilogue_player", undefined);
        Sentry.setTag("epilogue", undefined);
        Sentry.setTag("epilogue_gallery", undefined);
    }

    $(document).off('keyup');
    $(document).keyup(groupSelectKeyToggle);

	clearTimeout(timeoutID); // No error if undefined or no longer valid
	timeoutID = autoForfeitTimeoutID = undefined;
	stopCardAnimations();
	resetPlayers();

	/* enable table opacity */
	tableOpacity = 1;
	$gameTable.css({opacity:1});
    $gamePlayerCardArea.show();
    $gamePlayerClothingArea.css('display', '');  /* Reset to default so as not to interfere with 
                                                    switching between classic and minimal UI. */
    inGame = false;

    if (SENTRY_INITIALIZED) {
        Sentry.setTag("in_game", false);
    }

	/* trigger screen refreshes */
	updateSelectionVisuals();
	updateAllGameVisuals();
    selectTitleCandy();

    forceTableVisibility(true);

	/* there is only one call to this right now */
	$epilogueSelectionModal.hide();
	$gameScreen.hide();
	$epilogueScreen.hide();
	clearEpilogue();
    $titleScreen.show();
}

/**********************************************************************
 *****                    Interaction Functions                   *****
 **********************************************************************/

/*
 * Bug Report Modal functions
 */

function getBugReportJSON() {
    var desc = $('#bug-report-desc').val();
    var type = $('#bug-report-type').val();
    var character = undefined;

    var report = compileBaseErrorReport(desc, type);
    return JSON.stringify(report);
}

function updateBugReportSendButton() {
    if($('#bug-report-desc').val().trim().length > 0) {
        $("#bug-report-modal-send-button").removeAttr('disabled');
    } else {
        $("#bug-report-modal-send-button").attr('disabled', 'true');
    }
}

$('#bug-report-desc').keyup(updateBugReportSendButton);

/* Update the bug report text dump. */
function updateBugReportOutput() {
    $('#bug-report-output').val(getBugReportJSON());
    $('#bug-report-status').text("");

    updateBugReportSendButton();
}

function copyBugReportOutput() {
    var elem = $('#bug-report-output')[0];
    elem.select();
    document.execCommand("copy");
}

function sendBugReport() {
    if($('#bug-report-desc').val().trim().length == 0) {
        $('#bug-report-status').text("Please enter a description first!");
        return false;
    }

    $.ajax({
        url: BUG_REPORTING_ENDPOINT,
        method: 'POST',
        data: getBugReportJSON(),
        contentType: 'application/json',
        error: function (jqXHR, status, err) {
            console.error("Could not send bug report - error "+status+": "+err);
            $('#bug-report-status').text("Failed to send bug report (error "+status+")");
        },
        success: function () {
            $('#bug-report-status').text("Bug report sent!");
            $('#bug-report-desc').val("");
            closeBugReportModal();
        }
    });

}

$('#bug-report-type').change(updateBugReportOutput);
$('#bug-report-desc').change(updateBugReportOutput);
$('#bug-report-copy-btn').click(copyBugReportOutput);

 /************************************************************
  * The player clicked a bug-report button. Shows the bug reports modal.
  ************************************************************/
function showBugReportModal () {
    /* Set up possible bug report types. */
    var bugReportTypes = [
        ['freeze', 'Game Freeze or Crash'],
        ['display', 'Game Graphical Problem'],
        ['other', 'Other Game Issue'],
    ]

    for (var i=1;i<5;i++) {
        if (players[i]) {
            var mixedCaseID = players[i].id.charAt(0).toUpperCase()+players[i].id.substring(1);
            bugReportTypes.push(['character:'+players[i].id, 'Character Defect ('+mixedCaseID+')']);
        }
    }

    $('#bug-report-type').empty().append(bugReportTypes.map(function (t) {
        return $('<option value="'+t[0]+'">'+t[1]+'</option>');
    }));

    updateBugReportOutput();

    $bugReportModal.modal('show');
}

$bugReportModal.on('shown.bs.modal', function() {
	$('#bug-report-type').focus();
});

function closeBugReportModal() {
    $bugReportModal.modal('hide');
}

/*
 * Show the usage tracking consent modal.
 */

function showUsageTrackingModal() {
    $usageTrackingModal.modal('show');
}

function enableUsageTracking() {
    USAGE_TRACKING = true;
    save.saveUsageTracking();
    sentryInit();
}

function disableUsageTracking() {
    USAGE_TRACKING = false;
    save.saveUsageTracking();
}

function sentryInit() {
    if (USAGE_TRACKING && !SENTRY_INITIALIZED) {
        console.log("Initializing Sentry...");

        var sentry_opts = {
            dsn: 'https://df511167a4fa4a35956a8653ff154960@sentry.io/1508488',
            release: VERSION_COMMIT,
            maxBreadcrumbs: 250,
            integrations: [new Sentry.Integrations.Breadcrumbs({
                console: false,
                dom: false
            })],
            beforeSend: function (event, hint) {
                /* Inject additional game state data into event tags: */
                if (inGame) {
                    if (!event.extra) event.extra = {};

                    event.extra.recentLoser = recentLoser;
                    event.extra.previousLoser = previousLoser;
                    event.extra.gameOver = gameOver;
                    event.extra.currentTurn = currentTurn;
                    event.extra.currentRound = currentRound;

                    event.tags.rollback = inRollback();
                    event.tags.gamePhase = getGamePhaseString(gamePhase);
                }

                var n_players = 0;
                for (var i=1;i<players.length;i++) {
                    if (players[i]) {
                        n_players += 1;
                        event.tags["character:" + players[i].id] = true;
                        event.tags["slot-" + i] = players[i].id;
                    } else {
                        event.tags["slot-" + i] = undefined;
                    }
                }

                event.tags.n_players = n_players;

                return event;
            }
        };

        if (window.location.origin.indexOf('spnati.net') >= 0) {
            sentry_opts.environment = 'production';
        }

        Sentry.init(sentry_opts);

        Sentry.setUser({
            'id': sessionID,
        });

        Sentry.setTag("game_version", CURRENT_VERSION);

        SENTRY_INITIALIZED = true;
    }
}

var SEMVER_RE = /[vV]?(\d+)\.(\d+)(?:\.(\d+))?(?:\-([a-zA-Z0-9\-]+(?:\.[a-zA-Z0-9\-])*))?(?:\+([a-zA-Z0-9\-]+(?:\.[a-zA-Z0-9\-]+)*))?/;
function parseSemVer (versionString) {
    var m = versionString.match(SEMVER_RE);
    if (!m) return null;
    
    var ver = {
        'major': parseInt(m[1], 10) || 0,
        'minor': parseInt(m[2], 10) || 0,
        'patch': parseInt(m[3], 10) || 0,
    }
    
    if (m[4]) {
        ver.prerelease = m[4].split('.');
    }
    
    return ver;
}

/* Implements semver precedence rules, as specified in the Semantic Versioning 2.0.0 spec. */
function compareVersions (v1, v2) {
    var m1 = parseSemVer(v1);
    var m2 = parseSemVer(v2);
    
    // compare major - minor - patch first, in that order
    if (m1.major !== m2.major) return (m1.major > m2.major) ? 1 : -1;
    if (m1.minor !== m2.minor) return (m1.minor > m2.minor) ? 1 : -1;
    if (m1.patch !== m2.patch) return (m1.patch > m2.patch) ? 1 : -1;
    
    // prerelease versions always have less precedence than release versions
    if (!m1.prerelease && m2.prerelease) return 1;
    if (!m2.prerelease && m1.prerelease) return -1;
    
    // Compare pre-release identifiers from left to right
    for (var i=0;i<Math.min(m1.prerelease.length, m2.prerelease.length);i++) {
        var pr1 = parseInt(m1.prerelease[i], 10);
        var pr2 = parseInt(m2.prerelease[i], 10);
        
        if (m1.prerelease[i] !== m2.prerelease[i]) {
            // if both are numerical or both are non-numeric
            if (isNaN(pr1) === isNaN(pr2)) return (pr1 > pr2) ? 1 : -1;
            
            // otherwise, if we're comparing numeric to non-numeric,
            // numeric PR identifiers always have less precedence than non-numeric
            return isNaN(pr1) ? 1 : -1;
        }
    }
    
    // All pre-release identifiers to now compared equal
    // in this case, the version with a larger set of pre-release fields has
    // higher precedence
    if (m1.prerelease.length !== m2.prerelease.length) {
        return (m1.prerelease.length > m2.prerelease.length) ? 1 : -1;
    }
    
    // If all else fails, both versions compare equal
    return 0;
}

$creditModal.on('shown.bs.modal', function() {
	$('#credit-modal-button').focus();
});

/************************************************************
 * The player clicked the version button. Shows the version modal.
 ************************************************************/
function showVersionModal () {
    var $changelog = $('#changelog-container');
    var entries = [];
    
    /* Get changelog info: */
    versionInfo.find('changelog > version').each(function (idx, elem) {
        entries.push({
            version: $(elem).attr('number'),
            timestamp: parseInt($(elem).attr('timestamp'), 10) || undefined,
            text: $(elem).text()
        });
    });
    
    /* Construct the version modal DOM: */
    $changelog.empty().append(entries.sort(function (e1, e2) {
        // Sort in reverse-precedence order
        return compareVersions(e1.version, e2.version) * -1;
    }).map(function (ent) {
        var row = document.createElement('tr');
        var versionCell = document.createElement('td');
        var dateCell = document.createElement('td');
        var logTextCell = document.createElement('td');
        
        versionCell.className = 'changelog-version-label';
        dateCell.className = 'changelog-date-label';
        logTextCell.className = 'changelog-entry-text';
        
        if (ent.timestamp) {
            var date = new Date(ent.timestamp);
            var locale = window.navigator.userLanguage || window.navigator.language
            dateCell.innerText = date.toLocaleDateString(locale, {month: 'long', day: 'numeric', year: 'numeric'});
        }
        
        versionCell.innerText = ent.version;
        logTextCell.innerText = ent.text;
        
        row.appendChild(versionCell);
        row.appendChild(dateCell);
        row.appendChild(logTextCell);
        
        return row;
    }));
    
    $versionModal.modal('show');
}

$versionModal.on('shown.bs.modal', function() {
	$('#version-modal-button').focus();
});

/************************************************************
 * The player clicked the help / FAQ button. Shows the help modal.
 ************************************************************/
function showHelpModal () {
    $helpModal.modal('show');
}

$('.help-page-select').click(function (ev) {
    var toPage = $(ev.target).attr('data-select-page');
    var curPage = $helpModal.attr('data-current-page');
    curPage = parseInt(curPage, 10) || 1;
    
    if (toPage === 'prev') {
        curPage = (curPage > 1) ? curPage-1 : 1;
    } else if (toPage === 'next') {
        curPage = (curPage < 7) ? curPage+1 : 7;
    } else {
        curPage = toPage;
    }
    
    $helpModal.attr('data-current-page', curPage);
    $('.help-page').hide();
    $('.help-page[data-page="'+curPage+'"]').show();
    $('.help-page-select').removeClass('active');
    $('.help-page-select[data-page="'+curPage+'"]').addClass('active');
})

/************************************************************
 * The player clicked the player tags button. Shows the player tags modal.
 ************************************************************/
function showPlayerTagsModal () {
    if (document.forms['player-tags'].elements.length <= 6) {
        // Safari doesn't support color inputs properly!
        var hairColorPicker = document.getElementById('hair_color_picker');
        var selectionType;
        try {
            selectionType = typeof hairColorPicker.selectionStart;
        } catch(e) {
            selectionType = null;
        }
        for (var choiceName in playerTagOptions) {
            var replace = (choiceName != 'skin_color' || selectionType === 'number');
            var $existing = $('form#player-tags [name="'+choiceName+'"]');
            if (!replace && $existing.length) continue;
            var $select = $('<select>', { name: choiceName });
            $select.append('<option>', playerTagOptions[choiceName].values.map(function(opt) {
                return $('<option>').val(opt.value).addClass(opt.gender).append(opt.text || opt.value.replace(/_/g, ' ').initCap());
            }));
            if ($existing.length) {
                $existing.parent().replaceWith($select);
            } else {
                var $label = $('<label class="player-tag-select">');
                if (playerTagOptions[choiceName].gender) {
                    $select.addClass(playerTagOptions[choiceName].gender);
                    $label.addClass(playerTagOptions[choiceName].gender);
                }
                $label.append('Choose your ' + choiceName.replace(/_/g, ' ') + ':', $select);
                $('form#player-tags').append($label);
            }
        }

        var rgb2hsv = function(rgb) {
          var r = parseInt(rgb.slice(1,3), 16)/255;
          var g = parseInt(rgb.slice(3,5), 16)/255;
          var b = parseInt(rgb.slice(5), 16)/255;

          var min = Math.min(r, Math.min(g,b));
          var max = Math.max(r, Math.max(g,b));

          if (max === 0) {
            return [0,0,0];
          }

          var maxOffset = max === r ? 0 : (max === g ? 2 : 4);
          var delta = max === r ? g-b : (max === g ? b-r : r-g);

          var h = 60 * (maxOffset + delta / (max - min));
          if (h < 0) {
            h += 360;
          }

          return [h, (max - min) / max * 100, max * 100];
        }

        /* convert the raw colors to corresponding tags and display next to selector */
        $('input[type=color]').on('input', function() {
            var h, s, v;
            [h,s,v] = rgb2hsv(this.value);

            var tag;
            color2tag:
            if (this.id === 'hair_color_picker') {
              if (v < 10) {
                tag = 'black_hair';
                break color2tag;
              }

              if (s < 25) {
                if (v < 30) {
                  tag = 'black_hair';
                } else {
                  tag = 'white_hair';
                }
                break color2tag;
              }

              if (s < 50 && h > 20 && h < 50) {
                tag = 'brunette';
                break color2tag;
              }

              if (h < 50) {
                tag = 'ginger';
              } else if (h < 65) {
                tag = 'blonde';
              } else if (h < 325) {
                if (h < 145) {
                  tag = 'green_hair';
                } else if (h < 255) {
                  tag = 'blue_hair';
                } else if (h < 290) {
                  tag = 'purple_hair';
                } else {
                  tag = 'pink_hair';
                }
              } else {
                tag = 'ginger';
              }
            } else if (this.id === 'eye_color_picker') {

              if (v < 25) {
                tag = 'dark_eyes';
                break color2tag;
              }

              if (s < 20) {
                tag = 'pale_eyes';
                break color2tag;
              }

              if (h < 15) {
                tag = 'red_eyes';
              } else if (h < 65) {
                tag = 'amber_eyes';
              } else if (h < 145) {
                tag = 'green_eyes';
              } else if (h < 255) {
                tag = 'blue_eyes';
              } else if (h < 325) {
                tag = 'violet_eyes';
              } else {
                tag = 'red_eyes';
              }
            }

            this.previousElementSibling.value = tag || '';
        });

        $('input[name=skin_color]').on('input', function() {
            for (var i = 0; i < playerTagOptions['skin_color'].values.length; i++) {
                if (this.value <= playerTagOptions['skin_color'].values[i].to) {
                    tag = playerTagOptions['skin_color'].values[i].value;
                    break;
                }
            }

            this.previousElementSibling.value = tag || '';
        });

        $('.modal-button.clearSelections').click(function() {
            var formElements = document.forms['player-tags'].elements;
            for (var i = 0; i < formElements.length; i++) {
                if (formElements[i].type !== 'color') {
                    formElements[i].value = '';
                }
            }
        });
    }

    for (var choiceName in playerTagOptions) {
        $('form#player-tags [name="'+choiceName+'"]').val(playerTagSelections[choiceName]).trigger('input');
    }
    $('#player-tags-confirm').one('click', function() {
        playerTagSelections = {};
        for (var choiceName in playerTagOptions) {
            if (!('gender' in playerTagOptions[choiceName]) || playerTagOptions[choiceName].gender == humanPlayer.gender) {
                var val = $('form#player-tags [name="'+choiceName+'"]').val();
                if (val) {
                    playerTagSelections[choiceName] = val;
                }
            }
        }
    });
    $playerTagsModal.modal('show');
}

/************************************************************
 * The player clicked on a table opacity button.
 ************************************************************/
function toggleTableVisibility () {
	if (tableOpacity > 0) {
		$gameTable.fadeOut();
		tableOpacity = 0;
	} else {
		$gameTable.fadeIn();
		tableOpacity = 1;
	}
}

function forceTableVisibility(state) {
    if (!state) {
		$gameTable.fadeOut();
		tableOpacity = 0;
	} else {
		$gameTable.fadeIn();
		tableOpacity = 1;
	}
}

/************************************************************
 * The player clicked on a Load/Save button.
 ************************************************************/
function showImportModal() {
    $("#export-code").text(save.serializeLocalStorage());
    
    $('#import-invalid-code').hide();

    if (codeImportEnabled) {
        $('#import-progress').prop('disabled', false);
        $('#import-restriction-warning').hide();
    } else {
        $('#import-progress').prop('disabled', true);
        $('#import-restriction-warning').show();
    }
    
    $ioModal.modal('show');

    $('#import-progress').click(function() {
        var code = $("#export-code").val();

        if (SENTRY_INITIALIZED) {
            Sentry.addBreadcrumb({
                category: 'ui',
                message: 'Loading save code...',
                level: 'info'
            });
        }

        if (save.deserializeLocalStorage(code)) {
            $ioModal.modal('hide');
        } else {
            $('#import-invalid-code').show();
        }
    });
}

/**********************************************************************
 *****                     Utility Functions                      *****
 **********************************************************************/

/************************************************************
 * Returns a random number in a range.
 ************************************************************/
function getRandomNumber (min, max) {
	return Math.floor(Math.random() * (max - min) + min);
}

/************************************************************
 * Changes the first letter in a string to upper case.
 ************************************************************/
String.prototype.initCap = function() {
	return this.substr(0, 1).toUpperCase() + this.substr(1);
}

// Polyfills for IE
if (!String.prototype.startsWith) {
    Object.defineProperty(String.prototype, 'startsWith', {
        value: function(search, pos) {
            pos = !pos || pos < 0 ? 0 : +pos;
            return this.substring(pos, pos + search.length) === search;
        }
    });
}

if (!String.prototype.endsWith) {
    String.prototype.endsWith = function (search, this_len) {
        if (this_len === undefined || this_len > this.length) {
            this_len = this.length;
        }
        return this.substring(this_len - search.length, this_len) === search;
    };
}

/************************************************************
 * Counts the number of elements that evaluate as true, or,
 * if a function is provided, passes the test implemented by it.
 ************************************************************/
Object.defineProperty(Array.prototype, 'countTrue', {
    value: function(func) {
        var count = 0;
        for (var i = 0; i < this.length; i++) {
            if (i in this
                && (func ? func(this[i], i, this) : this[i])) {
                count++;
            }
        }
        return count;
    }
});

/************************************************************
 * Generate a random alphanumeric ID.
 ************************************************************/
function generateRandomID() {
    var ret = ''
    for (let i=0;i<10;i++) {
        ret += 'abcdefghijklmnopqrstuvwxyz1234567890'[getRandomNumber(0,36)]
    }

    return ret;
}

/**********************************************************************
 * Automatically adjusts the size of all font based on screen width.
 **/
function autoResizeFont ()
{
    var w = window.innerWidth, h = window.innerHeight;
    /* resize font. Note: the 3/2 threshold must match the @media CSS block */
    if ($('.wide-screen-container .screen:visible').width() && w / h >= 3/2) {
        // Calculate 4/3 of the height a normal screen would have ((16/9) / (4/3) = 4/3) 
        $(':root').css('font-size', ($('.screen:visible').width() / 4 * 3 / 100)+'px');
    } else if ($('.screen:visible').width()) {
        $(':root').css('font-size', ($('.screen:visible').width() / 100)+'px');
    } else if ($('.epilogue-viewport:visible').height()) {
        $(':root').css('font-size', ($('.epilogue-viewport:visible').height() / 75)+'px');
    }

	if (backgroundImage && backgroundImage.height && backgroundImage.width) {
		if (h > (3/4) * w) {
			h = (3/4) * w;
		} else {
			w = 4 * h / 3;
		}
		var ar = backgroundImage.width / backgroundImage.height;
		if (ar > 4/3) {
			var scale = Math.sqrt(16/9 / ar);
			$("body").css("background-size", "auto " + Math.round(scale * h) + "px");
		} else {
			var scale = Math.sqrt(ar);
			$("body").css("background-size", Math.round(scale * w) + "px auto");
		}
	}
	/* set up future resizing */
	window.onresize = autoResizeFont;
}

$('.modal').on('show.bs.modal', function() {
	$('.screen:visible').find('button, input').attr('tabIndex', -1);
});

$('.modal').on('hidden.bs.modal', function() {
	$('.screen:visible').find('button, input').removeAttr('tabIndex');
});