<!DOCTYPE html>
<html>
<!--
Copyright 2010 by Dan Fabulich.

Dan Fabulich licenses this file to you under the
ChoiceScript License, Version 1.0 (the "License"); you may
not use this file except in compliance with the License. 
You may obtain a copy of the License at

 http://www.choiceofgames.com/LICENSE-1.0.txt

See the License for the specific language governing
permissions and limitations under the License.

Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
either express or implied.
-->
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name = "viewport" content = "width = device-width, initial-scale = 1.0, maximum-scale = 1.0">
<!-- INSERT correct meta values -->
<!-- <title>Multiple Choice Example Game | My First ChoiceScript Game</title> -->

<script>window.version="UNKNOWN"</script>





<style id="dynamic"></style>





<!--[if IE 6]><style>.alertify-logs { position: absolute; }</style><![endif]-->
<meta name="apple-mobile-web-app-capable" content="yes" />
<script>
// INSERT store name; disabled by default because it's confusing for newbie authors
window.storeName = null;
//Scene.generatedFast = true;
var rootDir = "../";
</script>
<meta charset='UTF-8'>
<script>//
// Copyright (c) 2008, 2009 Paul Duncan (paul@pablotron.org)
// 
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// 
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
// 
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
//


/* 
 * The contents of gears_init.js; we need this because Chrome supports
 * Gears out of the box, but still requires this constructor.  Note that
 * if you include gears_init.js then this function does nothing.
 */
(function() {
  // We are already defined. Hooray!
  if (window.google && google.gears)
    return;

  // factory 
  var F = null;

  // Firefox
  if (typeof GearsFactory != 'undefined') {
    F = new GearsFactory();
  } else {
    // IE
    try {
      F = new ActiveXObject('Gears.Factory');
      // privateSetGlobalObject is only required and supported on WinCE.
      if (F.getBuildInfo().indexOf('ie_mobile') != -1)
        F.privateSetGlobalObject(this);
    } catch (e) {
      // Safari
      if ((typeof navigator.mimeTypes != 'undefined')
           && navigator.mimeTypes["application/x-googlegears"]) {
        F = document.createElement("object");
        F.style.display = "none";
        F.width = 0;
        F.height = 0;
        F.type = "application/x-googlegears";
        document.documentElement.appendChild(F);
      }
    }
  }

  // *Do not* define any objects if Gears is not installed. This mimics the
  // behavior of Gears defining the objects in the future.
  if (!F)
    return;

  // Now set up the objects, being careful not to overwrite anything.
  //
  // Note: In Internet Explorer for Windows Mobile, you can't add properties to
  // the window object. However, global objects are automatically added as
  // properties of the window object in all browsers.
  if (!window.google)
    google = {};

  if (!google.gears)
    google.gears = {factory: F};
})();

/**
 * Persist - top-level namespace for Persist library.
 * @namespace
 */
Persist = (function() {
  var VERSION = '0.2.0', P, B, esc, init, empty, ec;

  // easycookie 0.2.1 (pre-minified)
  // (see http://pablotron.org/software/easy_cookie/)
  ec = (function(){var EPOCH='Thu, 01-Jan-1970 00:00:01 GMT',RATIO=1000*60*60*24,KEYS=['expires','path','domain'],esc=escape,un=unescape,doc=document,me;var get_now=function(){var r=new Date();r.setTime(r.getTime());return r;}
var cookify=function(c_key,c_val){var i,key,val,r=[],opt=(arguments.length>2)?arguments[2]:{};r.push(esc(c_key)+'='+esc(c_val));for(i=0;i<KEYS.length;i++){key=KEYS[i];if(val=opt[key])
r.push(key+'='+val);}
if(opt.secure)
r.push('secure');return r.join('; ');}
var alive=function(){var k='__EC_TEST__',v=new Date();v=v.toGMTString();this.set(k,v);this.enabled=(this.remove(k)==v);return this.enabled;}
me={set:function(key,val){var opt=(arguments.length>2)?arguments[2]:{},now=get_now(),expire_at,cfg={};if(opt.expires){cfg.expires=new Date(now.getTime()+opt.expires*RATIO);cfg.expires=cfg.expires.toGMTString();}
var keys=['path','domain','secure'];for(i=0;i<keys.length;i++)
if(opt[keys[i]])
cfg[keys[i]]=opt[keys[i]];var r=cookify(key,val,cfg);doc.cookie=r;return val;},has:function(key){key=esc(key);var c=doc.cookie,ofs=c.indexOf(key+'='),len=ofs+key.length+1,sub=c.substring(0,key.length);return((!ofs&&key!=sub)||ofs<0)?false:true;},get:function(key){key=esc(key);var c=doc.cookie,ofs=c.indexOf(key+'='),len=ofs+key.length+1,sub=c.substring(0,key.length),end;if((!ofs&&key!=sub)||ofs<0)
return null;end=c.indexOf(';',len);if(end<0)
end=c.length;return un(c.substring(len,end));},remove:function(k){var r=me.get(k),opt={expires:EPOCH};doc.cookie=cookify(k,'',opt);return r;},keys:function(){var c=doc.cookie,ps=c.split('; '),i,p,r=[];for(i=0;i<ps.length;i++){p=ps[i].split('=');r.push(un(p[0]));}
return r;},all:function(){var c=doc.cookie,ps=c.split('; '),i,p,r=[];for(i=0;i<ps.length;i++){p=ps[i].split('=');r.push([un(p[0]),un(p[1])]);}
return r;},version:'0.2.1',enabled:false};me.enabled=alive.call(me);return me;}());

  // wrapper for Array.prototype.indexOf, since IE doesn't have it
  var index_of = (function() {
    if (Array.prototype.indexOf)
      return function(ary, val) { 
        return Array.prototype.indexOf.call(ary, val);
      };
    else
      return function(ary, val) {
        var i, l;

        for (i = 0, l = ary.length; i < l; i++)
          if (ary[i] == val)
            return i;

        return -1;
      };
  })();


  // empty function
  empty = function() { };

  /**
   * Escape spaces and underscores in name.  Used to generate a "safe"
   * key from a name.
   *
   * @private
   */
  esc = function(str) {
    return 'PS' + str.replace(/_/g, '__').replace(/ /g, '_s');
  };

  C = {
    /* 
     * Backend search order.
     * 
     * Note that the search order is significant; the backends are
     * listed in order of capacity, and many browsers
     * support multiple backends, so changing the search order could
     * result in a browser choosing a less capable backend.
     */ 
    search_order: [
      // TODO: air
      'steamworksStorage',
      'cefStorage',
      'winOldStorage',
      'winStoreStorage',
      'macStorage',
      'iosStorage',
      'localChromeStorage',
      'androidStorage',
      'indexed_db',
      'whatwg_db', 
      'localstorage',
      'globalstorage', 
      'cookie',
      'gears',
      'ie', 
      'flash'
    ],

    // valid name regular expression
    name_re: /^[a-z][a-z0-9_ -]+$/i,

    // list of backend methods
    methods: [
      'init', 
      'get', 
      'set', 
      'remove', 
      'load', 
      'save'
      // TODO: clear method?
    ],

    // sql for db backends (gears and db)
    sql: {
      version:  '1', // db schema version

      // XXX: the "IF NOT EXISTS" is a sqlite-ism; fortunately all the 
      // known DB implementations (safari and gears) use sqlite
      create:   "CREATE TABLE IF NOT EXISTS persist_data (k TEXT UNIQUE NOT NULL PRIMARY KEY, v TEXT NOT NULL)",
      get:      "SELECT v FROM persist_data WHERE k = ?",
      set:      "INSERT INTO persist_data(k, v) VALUES (?, ?)",
      remove:   "DELETE FROM persist_data WHERE k = ?" 
    },

    // default flash configuration
    flash: {
      // ID of wrapper element
      div_id:   '_persist_flash_wrap',

      // id of flash object/embed
      id:       '_persist_flash',

      // default path to flash object
      path: 'persist.swf',
      size: { w:1, h:1 },

      // arguments passed to flash object
      args: {
        autostart: true
      }
    } 
  };

  // built-in backends
  B = {
    // gears db backend
    // (src: http://code.google.com/apis/gears/api_database.html)
    gears: {
      // no known limit
      size:   -1,

      test: function() {
        // test for gears
        return (window.google && window.google.gears) ? true : false;
      },

      methods: {
        transaction: function(fn) {
          var db = this.db;

          // begin transaction
          db.execute('BEGIN').close();

          // call callback fn
          fn.call(this, db);

          // commit changes
          db.execute('COMMIT').close();
        },

        init: function() {
          var db;

          // create database handle (TODO: add schema version?)
          db = this.db = google.gears.factory.create('beta.database');

          // open database
          // from gears ref:
          //
          // Currently the name, if supplied and of length greater than
          // zero, must consist only of visible ASCII characters
          // excluding the following characters:
          //
          //   / \ : * ? " < > | ; ,
          //
          // (this constraint is enforced in the Store constructor)
          db.open(esc(this.name));

          // create table
          db.execute(C.sql.create).close();
        },

        get: function(key, fn, scope) {
          var r, sql = C.sql.get;

          // if callback isn't defined, then return
          if (!fn)
            return;

          // begin transaction
          this.transaction(function (t) {
            var is_valid, val;
            // exec query
            r = t.execute(sql, [key]);

            // check result and get value
            is_valid = r.isValidRow();
            val = is_valid ? r.field(0) : null;

            // close result set
            r.close();

            // call callback
            fn.call(scope || this, is_valid, val);
          });
        },

        set: function(key, val, fn, scope) {
          var rm_sql = C.sql.remove,
              sql    = C.sql.set, r;

          // begin set transaction
          this.transaction(function(t) {
            // exec remove query
            t.execute(rm_sql, [key]).close();

            // exec set query
            t.execute(sql, [key, val]).close();
            
            // run callback (TODO: get old value)
            if (fn)
              fn.call(scope || this, true, val);
          });
        },

        remove: function(key, fn, scope) {
          var get_sql = C.sql.get;
              sql = C.sql.remove,
              r, val = null, is_valid = false;

          // begin remove transaction
          this.transaction(function(t) {
            // if a callback was defined, then get the old
            // value before removing it
            if (fn) {
              // exec get query
              r = t.execute(get_sql, [key]);

              // check validity and get value
              is_valid = r.isValidRow();
              val = is_valid ? r.field(0) : null;

              // close result set
              r.close();
            }

            // exec remove query if no callback was defined, or if a
            // callback was defined and there was an existing value
            if (!fn || is_valid) {
              // exec remove query
              t.execute(sql, [key]).close();
            }

            // exec callback
            if (fn)
              fn.call(scope || this, is_valid, val);
          });
        } 
      }
    }, 

    steamworksStorage: {
      size:   -1,

      test: function() {
        try {
          return (typeof require !== "undefined" && require('steamworks.js'));
        } catch (e) {
          return false;
        }
      },

      methods: {
        key: function(key) {
          return esc(this.name) + esc(key);
        },

        init: function() {

        },

        get: function(key, fn, scope) {
          key = this.key(key);

          // if callback isn't defined, then return
          if (!fn)
            return;

          // get callback scope
          scope = scope || this;

          try {
            results = steamworks.cloud.readFile("store" + key);
            // steamworks.js fails with "" when the file doesn't exist
            if (results === "") return fn.call(scope, !"ok");
            var fixedResults = results.replace(/^.*\n/, "");
            if (!fixedResults.length) return fn.call(scope, !"ok");
            fn.call(scope, "ok", results.replace(/^.*\n/, ""));
          } catch (e) {
            fn.call(scope, !"ok");
          }
        },

        set: function(key, val, fn, scope) {
          key = this.key(key);
          scope = scope || this;
          var ok = steamworks.cloud.writeFile("store" + key, key+"\n"+val);
          if (fn) fn.call(scope, ok);
          return val;
        },

        // begin remove transaction
        remove: function(key, fn, scope) {
          key = this.key(key);
          scope = scope || this;
          var ok = steamworks.cloud.deleteFile("store" + key);
          if (fn) fn.call(scope, ok);
        }
      }
    },

    cefStorage: {
      size:   -1,

      test: function() {
        return !!window.cefQuery;
      },

      methods: {
        key: function(key) {
          return esc(this.name) + esc(key);
        },

        init: function() {

        },

        query: function(method, paramString, callback) {
          cefQuery({request:method+" "+paramString,
            onSuccess: function(response) {
              callback(true, response);
            },
            onFailure: function(error_code, error_message) {
              console.error(method + " error: " + error_message);
              callback(false);
            }
          });
        },

        get: function(key, fn, scope) {
          key = this.key(key);

          // if callback isn't defined, then return
          if (!fn)
            return;

          // get callback scope
          scope = scope || this;

          this.query("StorageGet", key, function(ok, results) {
            fn.call(scope, ok, results);
          });
        },

        set: function(key, val, fn, scope) {
          key = this.key(key);
          scope = scope || this;
          this.query("StorageSet", key + " " + val, function(ok){
            if (fn) fn.call(scope, ok, val);
          });
          return val;
        },

        // begin remove transaction
        remove: function(key, fn, scope) {
          key = this.key(key);
          scope = scope || this;
          this.query("StorageRemove", key, function(ok) {
            // return original value? meh
            if (fn) fn.call(scope, ok);
          });
        }
      }
    },

    indexed_db: {
      size: -1,
      test: function() {
        return window.indexedDB;
      },
      methods: {
        promisifyRequest: function (request) {
          return new Promise(function(resolve, reject) {
            request.oncomplete = request.onsuccess = function() { resolve(request.result) };
            request.onabort = request.onerror = function() { reject(request.error) };
          });
        },

        init: function() {
          var request = indexedDB.open(this.name);
          request.onupgradeneeded = function () { return request.result.createObjectStore('PersistJS'); };
          var dbp = this.promisifyRequest(request);
          this.getStore = function (txMode, callback) {
            return dbp.then(function (db) {
              return callback(db.transaction('PersistJS', txMode).objectStore('PersistJS'));
            });
          };
        },

        get: function (key, fn, scope) {
          scope = scope || this;
          var self = this;
          this.getStore('readonly', function(store) {
            self.promisifyRequest(store.get(key)).then(function (val) {
              fn.call(scope, true, val);
            })
          }).catch(function(error) {
            console.error(error);
            fn.call(scope, false);
          })
        },

        set: function (key, val, fn, scope) {
          scope = scope || this;
          var self = this;
          this.getStore('readwrite', function (store) {
            store.put(val, key);
            self.promisifyRequest(store.transaction).then(function () {
              if (fn) fn.call(scope, true, val);
            })
          }).catch(function (error) {
            console.error(error);
            if (fn) fn.call(scope, false);
          })
        },

        remove: function (key, val, fn, scope) {
          scope = scope || this;
          var self = this;
          this.getStore('readwrite', function (store) {
            store.delete(key);
            self.promisifyRequest(store.transaction).then(function () {
              if (fn) fn.call(scope, true);
            })
          }).catch(function (error) {
            console.error(error);
            if (fn) fn.call(scope, false);
          })
        },

      },
    },

    // whatwg db backend (webkit, Safari 3.1+)
    // (src: whatwg and http://webkit.org/misc/DatabaseExample.html)
    whatwg_db: {
      // size based on DatabaseExample from above (should I increase
      // this?)
      size:   200 * 1024,

      test: function() {
        var name = 'PersistJS Test', 
            desc = 'Persistent database test.';

        // test for openDatabase
        if (!window.openDatabase)
          return false;

        // make sure openDatabase works
        // XXX: will this leak a db handle and/or waste space?
        if (!window.openDatabase(name, C.sql.version, desc, B.whatwg_db.size))
          return false;

        // return true
        return true;
      },

      methods: {
        transaction: function(fn) {
          // lazy create database table;
          // this is done here because there is no way to
          // prevent a race condition if the table is created in init()
          if (!this.db_created) {
            this.db.transaction(function(t) {
              // create table
              t.executeSql(C.sql.create, [], function() {
                this.db_created = true;
              });
            }, empty); // trap exception
          } 

          // execute transaction
          this.db.transaction(fn);
        },

        init: function() {
          // create database handle
          this.db = openDatabase(
            this.name, 
            C.sql.version, 
            this.o.about || ("Persistent storage for " + this.name),
            this.o.size || B.whatwg_db.size 
          );
        },

        get: function(key, fn, scope) {
          var sql = C.sql.get;

          // if callback isn't defined, then return
          if (!fn)
            return;

          // get callback scope
          scope = scope || this;

          // begin transaction
          this.transaction(function (t) {
            t.executeSql(sql, [key], function(t, r) {
              if (r.rows.length > 0)
                fn.call(scope, true, r.rows.item(0)['v']);
              else
                fn.call(scope, false, null);
            });
          });
        },

        set: function(key, val, fn, scope) {
          var rm_sql = C.sql.remove,
              sql    = C.sql.set;

          // begin set transaction
          this.transaction(function(t) {
            // exec remove query
            t.executeSql(rm_sql, [key], function() {
              // exec set query
              t.executeSql(sql, [key, val], function(t, r) {
                // run callback
                if (fn)
                  fn.call(scope || this, true, val);
              });
            });
          });

          return val;
        },

        // begin remove transaction
        remove: function(key, fn, scope) {
          var get_sql = C.sql.get;
              sql = C.sql.remove;

          this.transaction(function(t) {
            // if a callback was defined, then get the old
            // value before removing it
            if (fn) {
              // exec get query
              t.executeSql(get_sql, [key], function(t, r) {
                if (r.rows.length > 0) {
                  // key exists, get value 
                  var val = r.rows.item(0)['v'];

                  // exec remove query
                  t.executeSql(sql, [key], function(t, r) {
                    // exec callback
                    fn.call(scope || this, true, val);
                  });
                } else {
                  // key does not exist, exec callback
                  fn.call(scope || this, false, null);
                }
              });
            } else {
              // no callback was defined, so just remove the
              // data without checking the old value

              // exec remove query
              t.executeSql(sql, [key]);
            }
          });
        } 
      }
    }, 
    
    // globalstorage backend (globalStorage, FF2+, IE8+)
    // (src: http://developer.mozilla.org/en/docs/DOM:Storage#globalStorage)
    // https://developer.mozilla.org/En/DOM/Storage
    //
    // TODO: test to see if IE8 uses object literal semantics or
    // getItem/setItem/removeItem semantics
    globalstorage: {
      // (5 meg limit, src: http://ejohn.org/blog/dom-storage-answers/)
      size: 5 * 1024 * 1024,

      test: function() {
        try {
          if (window.globalStorage && window.globalStorage[this.o.domain]) return true;
        } catch (e) {}
        return false;
      },

      methods: {
        key: function(key) {
          return esc(this.name) + esc(key);
        },

        init: function() {
          this.store = globalStorage[this.o.domain];
        },

        get: function(key, fn, scope) {
          // expand key
          key = this.key(key);

          if (fn)
            fn.call(scope || this, true, this.store.getItem(key));
        },

        set: function(key, val, fn, scope) {
          // expand key
          key = this.key(key);

          // set value
          this.store.setItem(key, val);

          if (fn)
            fn.call(scope || this, true, val);
        },

        remove: function(key, fn, scope) {
          var val;

          // expand key
          key = this.key(key);

          // get value
          val = this.store[key];

          // delete value
          this.store.removeItem(key);

          if (fn)
            fn.call(scope || this, (val !== null), val);
        } 
      }
    }, 
    
    // localstorage backend (globalStorage, FF2+, IE8+)
    // (src: http://www.whatwg.org/specs/web-apps/current-work/#the-localstorage)
    // also http://msdn.microsoft.com/en-us/library/cc197062(VS.85).aspx#_global
    localstorage: {
      // (unknown?)
      // ie has the remainingSpace property, see:
      // http://msdn.microsoft.com/en-us/library/cc197016(VS.85).aspx
      size: -1,

      test: function() {
        try {
          return window.localStorage ? true : false;
        } catch (e) {
          return false;
        }
      },

      methods: {
        key: function(key) {
          return esc(this.name) + esc(key);
        },

        init: function() {
          this.store = localStorage;
        },

        get: function(key, fn, scope) {
          // expand key
          key = this.key(key);

          if (fn)
            fn.call(scope || this, true, this.store.getItem(key));
        },

        set: function(key, val, fn, scope) {
          // expand key
          key = this.key(key);

          // set value
          this.store.setItem(key, val);

          if (fn)
            fn.call(scope || this, true, val);
        },

        remove: function(key, fn, scope) {
          var val;

          // expand key
          key = this.key(key);

          // get value
          val = this.store.getItem(key);

          // delete value
          this.store.removeItem(key);

          if (fn)
            fn.call(scope || this, (val !== null), val);
        } 
      }
    }, 

    // chrome packaged app storage
    // http://developer.chrome.com/stable/apps/storage.html
    localChromeStorage: {
      // (unknown?)
      // ie has the remainingSpace property, see:
      // http://msdn.microsoft.com/en-us/library/cc197016(VS.85).aspx
      size: -1,

      test: function() {
        try {
          return window.chrome && window.chrome.storage && window.chrome.storage.local;
        } catch (e) {
          return false;
        }
      },

      methods: {
        key: function(key) {
          return esc(this.name) + esc(key);
        },

        init: function() {
          this.store = chrome.storage.local;
        },

        get: function(key, fn, scope) {
          // expand key
          key = this.key(key);

          scope = scope || this;
          this.store.get(key, function(val){
            if (fn) fn.call(scope, true, val[key]);
          });
        },

        set: function(key, val, fn, scope) {
          // expand key
          key = this.key(key);

          var out = {};
          out[key] = val;
          if (fn) {
            scope = scope || this;  
            this.store.set(out, function(){
              fn.call(scope, true, val);
            });
          } else {
            this.store.set(out);
          }
        },

        remove: function(key, fn, scope) {
          var val;

          // expand key
          key = this.key(key);

          if (fn) {
            // get value first
            scope = scope || this;
            this.store.get(key, function(val){
              this.store.remove(key, function(){
                fn.call(scope, (val[key] !== null), val[key]);
              });
            });
          } else {
            this.store.remove(key);
          }
        } 
      }
    }, 
    
    // DGF Fake local storage
    androidStorage: {
      // (unknown?)
      // ie has the remainingSpace property, see:
      // http://msdn.microsoft.com/en-us/library/cc197016(VS.85).aspx
      size: -1,

      test: function() {
        try {
          return window.androidStorage ? true : false;
        } catch (e) {
          return false;
        }
      },

      methods: {
        key: function(key) {
          return esc(this.name) + esc(key);
        },

        init: function() {
          this.store = androidStorage;
        },

        get: function(key, fn, scope) {
          // expand key
          key = this.key(key);

          var val = this.store.getItem(key);
          if (val === undefined) val = null;

          if (fn)
            fn.call(scope || this, true, val);
        },

        set: function(key, val, fn, scope) {
          // expand key
          key = this.key(key);

          // set value
          this.store.setItem(key, val);

          if (fn)
            fn.call(scope || this, true, val);
        },

        remove: function(key, fn, scope) {
          var val;

          // expand key
          key = this.key(key);

          // get value
          val = this.store.getItem(key);
          if (val === undefined) val = null;

          // delete value
          this.store.removeItem(key);

          if (fn)
            fn.call(scope || this, (val !== null), val);
        } 
      }
    }, 

    // DGF iOS managed storage
    iosStorage: {
      size: -1,

      test: function() {
        try {
          var userAgent = navigator.userAgent;
          return (window.isIosApp || (/CoGnibus/.test(userAgent) && !/Android/.test(userAgent))) ? true : false;
        } catch (e) {
          return false;
        }
      },

      methods: {
        key: function(key) {
          return esc(this.name) + esc(key);
        },

        init: function() {

        },

        callIos: function(scheme, path) {
          if (typeof webkit !== "undefined" && webkit.messageHandlers) {
            return webkit.messageHandlers.choicescript.postMessage([scheme, path]);
          }
          path = encodeURIComponent(path).replace(/[!~*')(]/g, function(match) {
            return "%" + match.charCodeAt(0).toString(16);
          });

          var url = scheme + "://" + path;
          setTimeout(function() {
            var iframe = document.createElement("IFRAME");
            iframe.setAttribute("src", url);
            iframe.setAttribute("style", "display:none");
            document.documentElement.appendChild(iframe);
            iframe.parentNode.removeChild(iframe);
            iframe = null;
          }, 0);
        },

        get: function(key, fn, scope) {
          if (!fn) return;
          // expand key
          key = this.key(key);

          var nonce = "storageget" + key + (+new Date);
          var i = 0;
          while (window[nonce]) {
            nonce = "storageget" + key + (+new Date) + String(i++);
          }
          window[nonce] = function(value) {
            delete window[nonce];
            fn.call(scope || this, true, value);
          }
          this.callIos("storageget", key + " " + nonce);
        },

        set: function(key, val, fn, scope) {
          // expand key
          key = this.key(key);

          // set value
          var nonce = "storageset" + key + (+new Date);
          var i = 0;
          while (window[nonce]) {
            nonce = "storageget" + key + (+new Date) + String(i++);
          }
          window[nonce] = function() {
            delete window[nonce];
            if (fn) fn.call(scope || this, true, val);
          }
          this.callIos("storageset", key + " " + nonce + " " + encodeURIComponent(val));
        },

        remove: function(key, fn, scope) {
          if (fn) {
            this.get(key, function(val) {
              this._remove(key, fn, scope, val);
            }, this);
          } else {
            this._remove(key, fn, scope);
          }
        },

        _remove: function(key, fn, scope, val) {
          var val;

          // expand key
          key = this.key(key);

          // delete value
          var nonce = "storagerem" + key + (+new Date);
          var i = 0;
          while (window[nonce]) {
            nonce = "storageget" + key + (+new Date) + String(i++);
          }
          window[nonce] = function() {
            delete window[nonce];
            if (fn) fn.call(scope || this, (val !== null), val);
          }
          this.callIos("storagerem", key + " " + nonce);
        } 
      }
    }, 

    // DGF macOS managed storage
    macStorage: {
      size: -1,

      test: function () {
        return !!window.isMacApp;
      },

      methods: {
        key: function (key) {
          return esc(this.name) + esc(key);
        },

        init: function () {

        },

        callMac: function (scheme, path) {
          if (typeof webkit !== "undefined" && webkit.messageHandlers) {
            return webkit.messageHandlers.choicescript.postMessage([scheme, path]);
          }
        },

        get: function (key, fn, scope) {
          if (!fn) return;
          // expand key
          key = this.key(key);

          var nonce = "storageget" + key + (+new Date);
          var i = 0;
          while (window[nonce]) {
            nonce = "storageget" + key + (+new Date) + String(i++);
          }
          window[nonce] = function (value) {
            delete window[nonce];
            fn.call(scope || this, true, value);
          }
          this.callMac("storageget", key + " " + nonce);
        },

        set: function (key, val, fn, scope) {
          // expand key
          key = this.key(key);

          // set value
          var nonce = "storageset" + key + (+new Date);
          var i = 0;
          while (window[nonce]) {
            nonce = "storageget" + key + (+new Date) + String(i++);
          }
          window[nonce] = function () {
            delete window[nonce];
            if (fn) fn.call(scope || this, true, val);
          }
          this.callMac("storageset", key + " " + nonce + " " + encodeURIComponent(val));
        },

        remove: function (key, fn, scope) {
          if (fn) {
            this.get(key, function (val) {
              this._remove(key, fn, scope, val);
            }, this);
          } else {
            this._remove(key, fn, scope);
          }
        },

        _remove: function (key, fn, scope, val) {
          var val;

          // expand key
          key = this.key(key);

          // delete value
          var nonce = "storagerem" + key + (+new Date);
          var i = 0;
          while (window[nonce]) {
            nonce = "storageget" + key + (+new Date) + String(i++);
          }
          window[nonce] = function () {
            delete window[nonce];
            if (fn) fn.call(scope || this, (val !== null), val);
          }
          this.callMac("storagerem", key + " " + nonce);
        }
      }
    }, 

    // DGF Old win app managed storage
    winOldStorage: {
      size: -1,

      test: function() {
        try {
          return window.external.IsWinOldApp();
        } catch (e) {
          return false;
        }
      },

      methods: {
        key: function(key) {
          return esc(this.name) + esc(key);
        },

        init: function() {
          this.store = window.external;
        },

        get: function(key, fn, scope) {
          // expand key
          key = this.key(key);

          if (fn)
            fn.call(scope || this, true, this.store.GetValue(key));
        },

        set: function(key, val, fn, scope) {
          // expand key
          key = this.key(key);

          // set value
          this.store.SetValue(key, val);

          if (fn)
            fn.call(scope || this, true, val);
        },

        remove: function(key, fn, scope) {
          var val;

          // expand key
          key = this.key(key);

          // get value
          val = this.store.GetValue(key)

          // delete value
          this.store.DeleteValue(key);

          if (fn)
            fn.call(scope || this, (val !== null), val);
        } 
      }
    }, 

      // DGF WinStore managed storage
    winStoreStorage: {
        size: -1,

        test: function () {
            try {
                return Windows.Storage.ApplicationData.current.roamingFolder;
            } catch (e) {
                return false;
            }
        },

        methods: {
            key: function (key) {
                return esc(this.name) + esc(key);
            },

            init: function () {
                var self = this;
                function doneLoadingWinStore() {
                  self.loaded = true;
                  for (var i = self.loadListeners.length - 1; i >= 0; i--) {
                    setTimeout(self.loadListeners[i], 0);
                  }
                }
                this.loaded = false;
                this.loadListeners = [];

                Windows.Storage.ApplicationData.current.roamingFolder
                  .createFileAsync("data.txt", Windows.Storage.CreationCollisionOption.openIfExists)
                  .then(function (file) {
                    self.file = file;
                    return Windows.Storage.FileIO.readTextAsync(file);
                  }).done(function (data) {
                    self.store = {};
                    if (data && typeof data == "string") {
                      var rows = data.split("\n");
                      for (var i = rows.length - 1; i >= 0; i--) {
                        var row = rows[i].split("\t");
                        self.store[row[0]] = decodeURIComponent(row[1]);
                      }
                    }
                    doneLoadingWinStore();
                  },
                  function(){
                    self.store = {};
                    doneLoadingWinStore();
                  });
            },

            get: function (key, fn, scope) {
                if (!this.loaded) {
                  var self = this;
                  this.loadListeners.push(function() {self.get(key, fn, scope)});
                  return;
                }
                // expand key
                key = this.key(key);

                if (fn)
                    fn.call(scope || this, true, this.store[key]);
            },

            set: function (key, val, fn, scope) {
                if (!this.loaded) {
                  var self = this;
                  this.loadListeners.push(function() {self.set(key, val, fn, scope)});
                  return;
                }
                // expand key
                key = this.key(key);

                // set value
                this.store[key] = val;
                this.writeAsync();

                if (fn)
                    fn.call(scope || this, true, val);
            },

            remove: function (key, fn, scope) {
                if (!this.loaded) {
                  var self = this;
                  this.loadListeners.push(function() {self.remove(key, fn, scope)});
                  return;
                }
                var val;

                // expand key
                key = this.key(key);

                // get value
                val = this.store[key];

                // delete value
                delete this.store[key];
                this.writeAsync();

                if (fn)
                    fn.call(scope || this, (val !== null), val);
            },

            writeAsync: function() {
              var output = [];
              for (var key in this.store) {
                output.push(key, '\t', encodeURIComponent(this.store[key]), '\n');
              }
              Windows.Storage.FileIO.writeTextAsync(this.file, output.join(''));
            }
        }
    },

    // IE backend
    ie: {
      prefix:   '_persist_data-',
      // style:    'display:none; behavior:url(#default#userdata);',

      // 64k limit
      size:     64 * 1024,

      test: function() {
        // make sure we're dealing with IE
        // (src: http://javariet.dk/shared/browser_dom.htm)
        return window.ActiveXObject ? true : false;
      },

      make_userdata: function(id) {
        var el = document.createElement('div');

        // set element properties
        // http://msdn.microsoft.com/en-us/library/ms531424(VS.85).aspx 
        // http://www.webreference.com/js/column24/userdata.html
        el.id = id;
        el.style.display = 'none';
        el.addBehavior('#default#userdata');

        // append element to body
        document.body.appendChild(el);

        // return element
        return el;
      },

      methods: {
        init: function() {
          var id = B.ie.prefix + esc(this.name);

          // save element
          this.el = B.ie.make_userdata(id);

          // load data
          if (this.o.defer)
            this.load();
        },

        get: function(key, fn, scope) {
          var val;

          // expand key
          key = esc(key);

          // load data
          if (!this.o.defer)
            this.load();

          // get value
          val = this.el.getAttribute(key);

          // call fn
          if (fn)
            fn.call(scope || this, val ? true : false, val);
        },

        set: function(key, val, fn, scope) {
          // expand key
          key = esc(key);
          
          // set attribute
          this.el.setAttribute(key, val);

          // save data
          if (!this.o.defer)
            this.save();

          // call fn
          if (fn)
            fn.call(scope || this, true, val);
        },

        remove: function(key, fn, scope) {
          var val;

          // expand key
          key = esc(key);

          // load data
          if (!this.o.defer)
            this.load();

          // get old value and remove attribute
          val = this.el.getAttribute(key);
          this.el.removeAttribute(key);

          // save data
          if (!this.o.defer)
            this.save();

          // call fn
          if (fn)
            fn.call(scope || this, val ? true : false, val);
        },

        load: function() {
          this.el.load(esc(this.name));
        },

        save: function() {
          this.el.save(esc(this.name));
        }
      }
    },

    // cookie backend
    // uses easycookie: http://pablotron.org/software/easy_cookie/
    cookie: {
      delim: ':',

      // 4k limit (low-ball this limit to handle browser weirdness, and 
      // so we don't hose session cookies)
      size: 4000,

      test: function() {
        // XXX: use easycookie to test if cookies are enabled
        return P.Cookie.enabled ? true : false;
      },

      methods: {
        key: function(key) {
          return this.name + B.cookie.delim + key;
        },

        get: function(key, fn, scope) {
          var val;

          // expand key 
          key = this.key(key);

          // get value
          val = ec.get(key);

          // call fn
          if (fn)
            fn.call(scope || this, val != null, val);
        },

        set: function(key, val, fn, scope) {
          // expand key 
          key = this.key(key);

          // save value
          ec.set(key, val, this.o);

          // call fn
          if (fn)
            fn.call(scope || this, true, val);
        },

        remove: function(key, val, fn, scope) {
          var val;

          // expand key 
          key = this.key(key);

          // remove cookie
          val = ec.remove(key)

          // call fn
          if (fn)
            fn.call(scope || this, val != null, val);
        } 
      }
    },

    // flash backend (requires flash 8 or newer)
    // http://kb.adobe.com/selfservice/viewContent.do?externalId=tn_16194&sliceId=1
    // http://livedocs.adobe.com/flash/8/main/wwhelp/wwhimpl/common/html/wwhelp.htm?context=LiveDocs_Parts&file=00002200.html
    flash: {
      test: function() {
        // TODO: better flash detection
        if (!window.deconcept || !window.deconcept.SWFObjectUtil)
          return false;

        // get the major version
        var major = deconcept.SWFObjectUtil.getPlayerVersion().major;

        // check flash version (require 8.0 or newer)
        return (major >= 8) ? true : false;
      },

      methods: {
        init: function() {
          if (!B.flash.el) {
            var o, key, el, cfg = C.flash;

            // create wrapper element
            el = document.createElement('div');
            el.id = cfg.div_id;

            // FIXME: hide flash element
            // el.style.display = 'none';

            // append element to body
            document.body.appendChild(el);

            // create new swf object
            o = new deconcept.SWFObject(this.o.swf_path || cfg.path, cfg.id, cfg.size.w, cfg.size.h, '8');

            // set parameters
            for (key in cfg.args)
              o.addVariable(key, cfg.args[key]);

            // write flash object
            o.write(el);

            // save flash element
            B.flash.el = document.getElementById(cfg.id);
          }

          // use singleton flash element
          this.el = B.flash.el;
        },

        get: function(key, fn, scope) {
          var val;

          // escape key
          key = esc(key);

          // get value
          val = this.el.get(this.name, key);

          // call handler
          if (fn)
            fn.call(scope || this, val !== null, val);
        },

        set: function(key, val, fn, scope) {
          var old_val;

          // escape key
          key = esc(key);

          // set value
          old_val = this.el.set(this.name, key, val);

          // call handler
          if (fn)
            fn.call(scope || this, true, val);
        },

        remove: function(key, fn, scope) {
          var val;

          // get key
          key = esc(key);

          // remove old value
          val = this.el.remove(this.name, key);

          // call handler
          if (fn)
            fn.call(scope || this, true, val);
        }
      }
    }
  };

  /**
   * Test for available backends and pick the best one.
   * @private
   */
  var init = function() {
    var i, l, b, key, fns = C.methods, keys = C.search_order;

    // set all functions to the empty function
    for (i = 0, l = fns.length; i < l; i++) 
      P.Store.prototype[fns[i]] = empty;

    // clear type and size
    P.type = null;
    P.size = -1;

    // loop over all backends and test for each one
    for (i = 0, l = keys.length; !P.type && i < l; i++) {
      b = B[keys[i]];

      // test for backend
      try {
        if (b.test()) {
          // found backend, save type and size
          P.type = keys[i];
          P.size = b.size;

          // extend store prototype with backend methods
          for (key in b.methods)
            P.Store.prototype[key] = b.methods[key];
        }
      } catch (e) {}
    }

    // mark library as initialized
    P._init = true;
  };

  // create top-level namespace
  P = {
    // version of persist library
    VERSION: VERSION,

    // backend type and size limit
    type: null,
    size: 0,

    // XXX: expose init function?
    // init: init,

    add: function(o) {
      // add to backend hash
      B[o.id] = o;

      // add backend to front of search order
      C.search_order = [o.id].concat(C.search_order);

      // re-initialize library
      init();
    },

    remove: function(id) {
      var ofs = index_of(C.search_order, id);
      if (ofs < 0)
        return;

      // remove from search order
      C.search_order.splice(ofs, 1);

      // delete from lut
      delete B[id];

      // re-initialize library
      init();
    },

    // expose easycookie API
    Cookie: ec,

    // store API
    Store: function(name, o) {
      // verify name
      if (!C.name_re.exec(name))
        throw new Error("Invalid name");

      // XXX: should we lazy-load type?
      // if (!P._init)
      //   init();

      if (!P.type)
        throw new Error("No suitable storage found");

      o = o || {};
      this.name = name;

      // get domain (XXX: does this localdomain fix work?)
      o.domain = o.domain || location.host || 'localhost';
      
      // strip port from domain (XXX: will this break ipv6?)
      o.domain = o.domain.replace(/:\d+$/, '')

      // append localdomain to domains w/o '."
      // (see https://bugzilla.mozilla.org/show_bug.cgi?id=357323)
      // (file://localhost/ works, see: 
      // https://bugzilla.mozilla.org/show_bug.cgi?id=469192)
/* 
 *       if (!o.domain.match(/\./))
 *         o.domain += '.localdomain';
 */ 

      this.o = o;

      // expires in 2 years
      o.expires = o.expires || 365 * 2;

      // set path to root
      o.path = o.path || '/';

      // call init function
      this.init();
    } 
  };

  // init persist
  init();

  // return top-level namespace
  return P;
})();
/**
 * alertify
 * An unobtrusive customizable JavaScript notification system
 *
 * @author Fabien Doiron <fabien.doiron@gmail.com>
 * @copyright Fabien Doiron 2012
 * @license MIT <http://opensource.org/licenses/mit-license.php>
 * @link http://fabien-d.github.com/alertify.js/
 * @module alertify
 * @version 0.3.0
 */
(function(e,t){"use strict";var n=e.document,r;r=function(){var r={},i={},s=!1,o={ENTER:13,ESC:27,SPACE:32},u=[],a,f,l,c,h;return i={buttons:{holder:'<nav class="alertify-buttons">{{buttons}}</nav>',submit:'<button type="submit" class="alertify-button alertify-button-ok" id="alertify-ok" />{{ok}}</button>',ok:'<a href="#" class="alertify-button alertify-button-ok" id="alertify-ok">{{ok}}</a>',cancel:'<a href="#" class="alertify-button alertify-button-cancel" id="alertify-cancel">{{cancel}}</a>'},input:'<div class="alertify-text-wrapper"><input type="text" class="alertify-text" id="alertify-text"></div>',message:'<p class="alertify-message">{{message}}</p>',log:'<article class="alertify-log{{class}}">{{message}}</article>'},a=function(e){return n.getElementById(e)},r={labels:{ok:"OK",cancel:"Cancel"},delay:5e3,addListeners:function(r){var i=a("alertify-resetFocus"),s=a("alertify-ok")||t,u=a("alertify-cancel")||t,f=a("alertify-text")||t,l=a("alertify-form")||t,c=typeof s!="undefined",h=typeof u!="undefined",p=typeof f!="undefined",d="",v=this,m,g,y,b,w;m=function(e){typeof e.preventDefault!="undefined"&&e.preventDefault(),y(e),typeof f!="undefined"&&(d=f.value),typeof r=="function"&&r(!0,d)},g=function(e){typeof e.preventDefault!="undefined"&&e.preventDefault(),y(e),typeof r=="function"&&r(!1)},y=function(e){v.hide(),v.unbind(n.body,"keyup",b),v.unbind(i,"focus",w),p&&v.unbind(l,"submit",m),c&&v.unbind(s,"click",m),h&&v.unbind(u,"click",g)},b=function(e){var t=e.keyCode;t===o.SPACE&&!p&&m(e),t===o.ESC&&h&&g(e)},w=function(e){p?f.focus():h?u.focus():s.focus()},this.bind(i,"focus",w),c&&this.bind(s,"click",m),h&&this.bind(u,"click",g),this.bind(n.body,"keyup",b),p&&this.bind(l,"submit",m),e.setTimeout(function(){f?(f.focus(),f.select()):s.focus()},50)},bind:function(e,t,n){typeof e.addEventListener=="function"?e.addEventListener(t,n,!1):e.attachEvent&&e.attachEvent("on"+t,n)},build:function(e){var t="",n=e.type,r=e.message,s=e.cssClass||"";t+='<div class="alertify-dialog">',n==="prompt"&&(t+='<form id="alertify-form">'),t+='<article class="alertify-inner">',t+=i.message.replace("{{message}}",r),n==="prompt"&&(t+=i.input),t+=i.buttons.holder,t+="</article>",n==="prompt"&&(t+="</form>"),t+='<a id="alertify-resetFocus" class="alertify-resetFocus" href="#">Reset Focus</a>',t+="</div>";switch(n){case"confirm":t=t.replace("{{buttons}}",i.buttons.cancel+i.buttons.ok),t=t.replace("{{ok}}",this.labels.ok).replace("{{cancel}}",this.labels.cancel);break;case"prompt":t=t.replace("{{buttons}}",i.buttons.cancel+i.buttons.submit),t=t.replace("{{ok}}",this.labels.ok).replace("{{cancel}}",this.labels.cancel);break;case"alert":t=t.replace("{{buttons}}",i.buttons.ok),t=t.replace("{{ok}}",this.labels.ok);break;default:}return c.className="alertify alertify-show alertify-"+n+" "+s,l.className="alertify-cover",t},close:function(e,t){var n=t&&!isNaN(t)?+t:this.delay;this.bind(e,"click",function(){h.removeChild(e)}),setTimeout(function(){typeof e!="undefined"&&e.parentNode===h&&h.removeChild(e)},n)},dialog:function(e,t,r,i,o){f=n.activeElement;var a=function(){if(c&&c.scrollTop!==null)return;a()};if(typeof e!="string")throw new Error("message must be a string");if(typeof t!="string")throw new Error("type must be a string");if(typeof r!="undefined"&&typeof r!="function")throw new Error("fn must be a function");return typeof this.init=="function"&&(this.init(),a()),u.push({type:t,message:e,callback:r,placeholder:i,cssClass:o}),s||this.setup(),this},extend:function(e){if(typeof e!="string")throw new Error("extend method must have exactly one paramter");return function(t,n){return this.log(t,e,n),this}},hide:function(){u.splice(0,1),u.length>0?this.setup():(s=!1,c.className="alertify alertify-hide alertify-hidden",l.className="alertify-cover alertify-hidden",f?f.focus():null)},init:function(){n.createElement("nav"),n.createElement("article"),n.createElement("section"),l=n.createElement("div"),l.setAttribute("id","alertify-cover"),l.className="alertify-cover alertify-hidden",n.body.appendChild(l),c=n.createElement("section"),c.setAttribute("id","alertify"),c.className="alertify alertify-hidden",n.body.appendChild(c),h=n.createElement("section"),h.setAttribute("id","alertify-logs"),h.className="alertify-logs",n.body.appendChild(h),n.body.setAttribute("tabindex","0"),delete this.init},log:function(e,t,n){var r=function(){if(h&&h.scrollTop!==null)return;r()};return typeof this.init=="function"&&(this.init(),r()),this.notify(e,t,n),this},notify:function(e,t,r){var i=n.createElement("article");i.className="alertify-log"+(typeof t=="string"&&t!==""?" alertify-log-"+t:""),i.innerHTML=e,h.insertBefore(i,h.firstChild),setTimeout(function(){i.className=i.className+" alertify-log-show"},50),this.close(i,r)},set:function(e){var t;if(typeof e!="object"&&e instanceof Array)throw new Error("args must be an object");for(t in e)e.hasOwnProperty(t)&&(this[t]=e[t])},setup:function(){var e=u[0];s=!0,c.innerHTML=this.build(e),typeof e.placeholder=="string"&&e.placeholder!==""&&(a("alertify-text").value=e.placeholder),this.addListeners(e.callback)},unbind:function(e,t,n){typeof e.removeEventListener=="function"?e.removeEventListener(t,n,!1):e.detachEvent&&e.detachEvent("on"+t,n)}},{alert:function(e,t,n){return r.dialog(e,"alert",t,"",n),this},confirm:function(e,t,n){return r.dialog(e,"confirm",t,"",n),this},extend:r.extend,init:r.init,log:function(e,t,n){return r.log(e,t,n),this},prompt:function(e,t,n,i){return r.dialog(e,"prompt",t,n,i),this},success:function(e,t){return r.log(e,"success",t),this},error:function(e,t){return r.log(e,"error",t),this},set:function(e){r.set(e)},labels:r.labels}},typeof define=="function"?define([],function(){return new r}):typeof e.alertify=="undefined"&&(e.alertify=new r)})(this);/*
 * Copyright 2010 by Dan Fabulich.
 * 
 * Dan Fabulich licenses this file to you under the
 * ChoiceScript License, Version 1.0 (the "License"); you may
 * not use this file except in compliance with the License. 
 * You may obtain a copy of the License at
 * 
 *  http://www.choiceofgames.com/LICENSE-1.0.txt
 * 
 * See the License for the specific language governing
 * permissions and limitations under the License.
 * 
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
 * either express or implied.
 */

_global = typeof globalThis !== "undefined" ? globalThis : this;

(function() {
  var userAgent, url, protocol, appMeta;
  if (typeof window !== "undefined") {
    userAgent = navigator.userAgent;
    url = window.location.href;
    protocol = window.location.protocol;
    appMeta = window.document.querySelector("meta[name=apple-itunes-app]");
  }
  _global.isWebOS = /webOS/.test(userAgent);
  _global.isMobile = _global.isWebOS || /Mobile/.test(userAgent);
  _global.isFile = /^file:/.test(url);
  _global.isXul = /^chrome:/.test(url);
  try {
    _global.steamworksApi = require('steamworks.js');
    _global.isSteamworks = true;
  } catch (ignored) {}
  _global.isWinOldApp = false;
  try {
    isWinOldApp = window.external.IsWinOldApp();
  } catch (ignored) {}
  _global.isAndroid = /Android/.test(userAgent);
  _global.isOmnibusApp = /CoGnibus/.test(userAgent);
  _global.isIosApp = _global.isIosApp || (_global.isOmnibusApp && !_global.isAndroid);
  _global.isAndroidApp = _global.isAndroidApp || (_global.isOmnibusApp && _global.isAndroid);
  _global.isAmazonAndroidApp = _global.isAmazonAndroidApp || (_global.isAndroidApp && _global.flavor && _global.flavor.isAmazon());
  _global.isWeb = !_global.isIosApp && !_global.isAndroidApp && !_global.isWinOldApp && /^https?:/.test(url);
  _global.isSecureWeb = /^https:?$/.test(protocol);
  _global.isSafari = /Safari/.test(userAgent);
  _global.isIE = /(MSIE|Trident)/.test(userAgent);
  _global.isIPad = /iPad/.test(userAgent);
  _global.isIPhone = /iPhone/.test(userAgent);
  _global.isKindleFire = /Kindle Fire/.test(userAgent);
  _global.isWinStoreApp = "ms-appx:" == protocol;
  _global.isCef = !!_global.cefQuery;
  _global.isNode = typeof process !== "undefined";
  _global.isHeartsChoice = appMeta && /1487052276/.test(appMeta.getAttribute("content"))
})();

_global.loadTime = new Date().getTime();

function callIos(scheme, path) {
  if (!_global.isIosApp) return;
  if (typeof webkit !== "undefined" && webkit.messageHandlers) {
    return webkit.messageHandlers.choicescript.postMessage([scheme, path]);
  }
  if (path) {
    path = encodeURIComponent(path).replace(/[!~*')(]/g, function(match) {
      return "%" + match.charCodeAt(0).toString(16);
    });
  } else {
    path = "";
  }
  setTimeout(function() {
    var iframe = document.createElement("IFRAME");
    iframe.setAttribute("src", scheme + "://" + path);
    iframe.setAttribute("style", "display:none");
    document.documentElement.appendChild(iframe);
    iframe.parentNode.removeChild(iframe);
    iframe = null;
  }, 0);
}

function callMac(scheme, path) {
  if (typeof webkit !== "undefined" && webkit.messageHandlers) {
    return webkit.messageHandlers.choicescript.postMessage([scheme, path]);
  }
}

function safeCall(obj, fn) {
    if (!fn) return;
    var isHeadless = typeof window == "undefined";
    var debug = false || (!isHeadless && window.debug);
    if (isIE || isHeadless) {
        // just call through; onerror will be called and debugger will handle it
        if (typeof MSApp != "undefined") {
            if (obj) {
                MSApp.execUnsafeLocalFunction(function () { fn.call(obj); });
            } else {
                MSApp.execUnsafeLocalFunction(fn);
            }
        } else if (obj) {
            fn.call(obj);
        } else {
            fn.call();
        }
    } else {
        try {
            if (obj) {
                fn.call(obj);
            } else {
                fn.call();
            }
        } catch (e) {
            if (e.message) {
              window.reportError(e.message, e.fileName, e.lineNumber, e.columnNumber, e);
            } else if (e.stack) {
              window.reportError(e.stack, e.fileName, e.lineNumber, e.columnNumber, e);
            } else {
              window.reportError(toJson(e, '\n'));
            }

            if (window.console) {
              window.console.error(e);
              if (e.message) window.console.error("Message: " + e.message);
              if (e.stack) window.console.error("Stack: " + e.stack);
            }
            // Rethrow here so the debugger can handle it
            // On Firefox this causes a second prompt.  Meh!
            if (debug) throw e;
        }
    }
}

function safeCallback(callback) {
  return function() {
    safeCall(null, callback);
  };
}

function safeTimeout(fn, time) {
  setTimeout(function() {
    safeCall(null, fn);
  }, time);
}

function isDefined(x) {
    return "undefined" !== typeof x;
}

function jsonStringifyAscii(obj) {
  var output = JSON.stringify(obj).replace(/(.)/g, function(x) {
    var code = x.charCodeAt(0);
    if (code > 127 || code < 32) {
     var outCode = code.toString(16);
     switch (outCode.length) {
       case 4:
         return "\\u" + outCode;
       case 3:
         return "\\u0" + outCode;
       case 2:
         return "\\u00" + outCode;
       case 1:
         return "\\u000" + outCode;
       default:
         return x;
     }
    }
    return x;
  });
  return output;
}

function toJson(obj, standardized) {
 if (typeof JSON != "undefined" && JSON.stringify) {
  return jsonStringifyAscii(obj);
 }
 switch (typeof obj) {
  case 'object':
   if (obj) {
    var list = [];
    if (obj instanceof Array) {
     for (var i=0;i < obj.length;i++) {
      list.push(toJson(obj[i], standardized));
     }
     return '[' + list.join(',') + ']';
    } else {
     for (var prop in obj) {
      if (prop == "scene") continue;
      if (!standardized && /^[a-zA-Z][a-zA-Z_0-9]\w+$/.test(prop) && !/\b(abstract|boolean|break|byte|case|catch|char|class|comment|const|continue|debugger|default|delete|do|double|else|enum|export|extends|false|final|finally|float|for|function|goto|if|implements|import|in|instanceof|int|interface|label|long|native|new|null|package|private|protected|public|return|short|static|super|switch|synchronized|this|throws|transient|true|try|typeof|var|void|volatile|while|with)\b/.test(prop)) {
        list.push(prop + ':' + toJson(obj[prop], standardized));
      } else {
        list.push('"' + prop + '":' + toJson(obj[prop], standardized));
      }
     }
     return '{' + list.join(',') + '}';
    }
   } else {
    return 'null';
   }
   break;
  case 'string':
   var encoded = obj.replace(/(.)/g, function(x) {
     if (x == "'" || x == '"' || x == '\\') {
       return "\\" + x;
     }
     var code = x.charCodeAt(0);
     if (code > 127 || code < 32) {
       var outCode = code.toString(16);
       switch (outCode.length) {
         case 4:
           return "\\u" + outCode;
         case 3:
           return "\\u0" + outCode;
         case 2:
           return "\\u00" + outCode;
         case 1:
           return "\\u000" + outCode;
         default:
           return x;
       }
     }
     return x;
   });
   return '"' + encoded + '"';
  case 'number':
  case 'boolean':
   return String(obj);
  case 'function':
   return 'badfunction';
  case 'undefined':
    return 'undefined';
  default:
   throw new Error("invalid type: " + typeof obj);
 }
}

var loginUrlBase = "https://www.choiceofgames.com/api/";
function xhrAuthRequest(method, endpoint, callback) {
  var paramBuilder = new Array(arguments.length*3);
  for (var i = 3; i < arguments.length; i=i+2) {
    if (i > 3) paramBuilder.push("&");
    paramBuilder.push(arguments[i]);
    paramBuilder.push("=");
    paramBuilder.push(arguments[i+1]);
  }
  var params = paramBuilder.join("");
  var xhr = findXhr();
  if (method == "POST") {
    xhr.open(method, loginUrlBase + endpoint + ".php", true);
    xhr.setRequestHeader("Content-type","application/x-www-form-urlencoded");
  } else {
    xhr.open(method, loginUrlBase + endpoint + ".php?" + params, true);
  }

  var done = false;

  xhr.onreadystatechange = function() {
    if (done) return;
    if (xhr.readyState != 4) return;
    done = true;
    var ok = xhr.status == 200;
    var response = {};
    try {
      if (xhr.responseText) response = JSON.parse(xhr.responseText);
    } catch (e) {
      ok = false;
    }
    if (!ok) {
      if (!response.error) response.error = "unknown error";
      response.status = xhr.status;
    }
    if (callback) safeCall(null, function() {callback(ok, response);});
  };
  xhr.send(params);
}

function login(email, password, register, subscribe, callback) {
  xhrAuthRequest("POST", "login", callback, "email", encodeURIComponent(email), "password", encodeURIComponent(password), "register", register, "subscribe", subscribe);
}

function forgotPassword(email, callback) {
  xhrAuthRequest("POST", "forgot", callback, "email", encodeURIComponent(email));
}

function logout(callback) {
  document.cookie = 'login=0;path=/;expires=Thu, 01 Jan 1970 00:00:01 GMT;';
  xhrAuthRequest("GET", "logout", callback);
  recordLogin(false);
  window.knownPurchases = null;
  window.registered = false;
  if (typeof FB != "undefined" && FB.logout) FB.logout();
  if (typeof gapi != "undefined" && gapi.auth && gapi.auth.signOut) gapi.auth.signOut();
}

function recordLogin(registered, loginId, email, callback) {
  if (initStore()) {
    if (registered) recordEmail(email);
    window.store.set("login", loginId || 0, function() {safeCall(null, callback);});
    window.registered = registered;
  } else {
    safeTimeout(callback, 0);
  }
}

function getRemoteEmail(callback) {
  xhrAuthRequest("GET", "getuser", callback);
}

function saveCookie(callback, slot, stats, temps, lineNum, indent, deleted, undeleted) {
    var value = computeCookie(stats, temps, lineNum, indent, deleted, undeleted);
    return writeCookie(value, slot, callback);
}

function computeCookie(stats, temps, lineNum, indent, deleted, undeleted) {
  var scene = stats.scene;
  delete stats.scene;
  if (scene) stats.sceneName = scene.name;
  var version = "UNKNOWN";
  if (typeof(window) != "undefined" && window && window.version) version = window.version;
  var obj = { version: version, stats: stats, temps: temps, lineNum: lineNum, indent: indent };
  if (deleted) obj.deleted = deleted;
  if (undeleted) obj.undeleted = undeleted;
  var value = toJson(obj);
  stats.scene = scene;
  return value;
}

function writeCookie(value, slot, callback) {
  if (!_global.pseudoSave) _global.pseudoSave = {};
  if (!slot) {
    slot = "";
  }
  _global.pseudoSave[slot] = value;
  if (!initStore()) {
    if (callback) safeTimeout(callback, 0);
    return;
  }
  window.store.set("state"+slot, value, safeCallback(callback));
}

function clearCookie(callback, slot) {
    writeCookie('', slot, safeCallback(callback));
}

function areSaveSlotsSupported() {
  return !!(initStore() && window.Persist.type != "cookie");
}

function recordSave(slot, callback) {
  if (!areSaveSlotsSupported()) {
    safeTimeout(callback, 0);
    return;
  }
  restoreObject(initStore(), "save_list", [], function (saveList) {
    saveList.push(slot);
    window.store.set("save_list", toJson(saveList), safeCallback(callback));
  });
}

function recordDirtySlots(slots, callback) {
  if (!areSaveSlotsSupported()) {
    safeTimeout(callback, 0);
    return;
  }
  restoreObject(initStore(), "dirty_save_list", [], function (saveList) {
    saveSet = {};
    for (var i = 0; i < saveList.length; i++) {
      saveSet[saveList[i]] = 1;
    }
    for (i = 0; i < slots.length; i++) {
      if (!saveSet[slots[i]]) saveList.push(slots[i]);
    }
    window.store.set("dirty_save_list", toJson(saveList), safeCallback(callback));
  });
}

function recordEmail(email, callback) {
  if (initStore()) {
    window.recordedEmail = email;
    window.store.set("email", email, safeCallback(callback));
  } else {
    safeTimeout(callback, 0);
  }
}

function fetchEmail(callback) {
  if (!initStore()) {
    safeTimeout(function(){callback("");}, 0);
    return;
  }
  if (window.recordedEmail) {
    return safeTimeout(function() {
      callback(window.recordedEmail);
    })
  }
  if (window.isWeb) {
    var cookieEmail = getCookieByName("login");
    if (/@/.test(cookieEmail)) {
      return recordEmail(cookieEmail, function() {
        callback(cookieEmail);
      });
    }
  }
  // For some reason, this get seems to not respond sometimes
  // adding a fallback timeout
  window.store.get("email", function(ok, value) {
    safeCall(null, function() {
      if (ok) window.recordedEmail = value;
      if (!callback) return;
      var temp = callback;
      callback = null;
      if (ok && value) {
        temp(value);
      } else {
        temp("");
      }
    });
  });
  safeTimeout(function() {
    if (!callback) return;
    var temp = callback;
    callback = null;
    temp("");
  }, 1000);
}

function restoreObject(store, key, defaultValue, callback) {
  if (!store) {
    safeTimeout(function() {callback(defaultValue);}, 0);
    return;
  }
  store.get(key, function(ok, value) {
    var result = defaultValue;
    if (ok && value) {
      try{
        result = jsonParse(value);
      } catch (e) {}
    }
    safeCall(null, function() {callback(result);});
  });
}

function getDirtySaveList(callback) {
  restoreObject(initStore(), "dirty_save_list", [], function (slotList) {
    callback(slotList);
  });
}

function remoteSaveMerger(i, callback) {
  var remoteStore = new Persist.Store(window.remoteStoreNames[i]);
  restoreObject(remoteStore, "save_list", [], function (remoteSlotList) {
    fetchSavesFromSlotList(remoteStore, remoteSlotList, 0, [], function(remoteSaveList) {
      mergeRemoteSaves(remoteSaveList, 0/*recordDirty*/, function() {
        i++;
        if (i < window.remoteStoreNames.length) {
          remoteSaveMerger(i, callback);
        } else {
          callback.apply(null, arguments);
        }
      });
    });
  });
}

function getSaves(callback) {
  if (window.remoteStoreNames && window.remoteStoreNames.length) {
    remoteSaveMerger(0, callback);
  } else if (window.remoteStoreName && window.storeName != window.remoteStoreName) {
    var remoteStore = new Persist.Store(window.remoteStoreName);
    restoreObject(remoteStore, "save_list", [], function (remoteSlotList) {
      fetchSavesFromSlotList(remoteStore, remoteSlotList, 0, [], function(remoteSaveList) {
        mergeRemoteSaves(remoteSaveList, 0/*recordDirty*/, callback);
      });
    });
  } else {
    restoreObject(initStore(), "save_list", [], function (localSlotList) {
      fetchSavesFromSlotList(initStore(), localSlotList, 0, [], callback);
    });
  }
}

function fetchSavesFromSlotList(store, slotList, i, saveList, callback) {
  if (i >= slotList.length) {
    return safeCall(null, function() {callback(saveList);});
  }
  restoreObject(store, "state"+slotList[i], null, function(saveState) {
    if (saveState) {
      saveState.timestamp = slotList[i].substring(4/*"save".length*/);
      saveList.push(saveState);
    }
    fetchSavesFromSlotList(store, slotList, i+1, saveList, callback);
  });
}

function isWebSavePossible() {
  if (!initStore()) return false;
  if (!_global.isCogPublished) return false;
  if (/^http/.test(window.location.protocol)) {
    return document.domain == window.webSaveDomain || document.domain == "localhost";
  }
  // if it's a file URL with a valid store, either you're a 3rd party developer
  // who knows what you're doing, or you're a mobile app
  return true;
}


webSaveDomain = "www.choiceofgames.com";
webSaveUrl = "https://" + webSaveDomain + "/ajax_proxy.php/websave";

function submitRemoteSave(slot, email, subscribe, callback) {
  if (!isWebSavePossible()) return safeTimeout(function() { callback(false); });
  window.store.get("state"+slot, function(ok, value) {
    if (ok) {
      var timestamp = slot.substring(4/*"save".length*/);
      var xhr = findXhr();
      var gameName = window.remoteStoreName || window.storeName;
      var params = "email="+email+"&game="+gameName+"&realGame="+window.storeName+"&json="+encodeURIComponent(value)+"&timestamp="+ timestamp+"&subscribe="+subscribe;
      xhr.open("POST", webSaveUrl,true);
      xhr.setRequestHeader("Content-type","application/x-www-form-urlencoded");
      var done = false;

      xhr.onreadystatechange = function() {
        if (done) return;
        if (xhr.readyState != 4) return;
        done = true;
        var ok = xhr.status == 200;
        if (ok) {
          safeCall(null, function() {callback(true);});
        } else {
          recordDirtySlots([slot], function() {
            safeCall(null, function() {callback(false);});
          });
        }
      };
      xhr.send(params);
    } else {
      recordDirtySlots([slot], function() {
        asyncAlert("There was a problem uploading the saved game. This is probably a bug; please contact support@choiceofgames.com with code 17891.", function() {
          safeCall(null, function() {callback(false);});
        });
      });
    }
  });
}

function submitDirtySaves(dirtySaveList, email, callback) {
  function submitDirtySave(i) {
    if (dirtySaveList[i]) {
      submitRemoteSave(dirtySaveList[i], email, false, function(ok) {
        if (ok) {
          submitDirtySave(i+1);
        } else {
          safeCall(null, function() {callback(false);});
        }
      });
    } else {
      window.store.remove("dirty_save_list", function() {
        safeCall(null, function() {callback(true);});
      });
    }
  }
  submitDirtySave(0);
}

function submitAnyDirtySaves(callback) {
  if (!callback) callback = function(ok) {};
  try {
    getDirtySaveList(function(dirtySaveList) {
      if (dirtySaveList && dirtySaveList.length) {
        try {
          fetchEmail(function(email) {
            if (email) {
              submitDirtySaves(dirtySaveList, email, callback);
            } else {
              callback(false);
            }
          });
        } catch (e) {
          callback(false);
        }
      }
    });
  } catch (e) {
    callback(false);
  }
}

function getRemoteSaves(email, callback) {
  if (!isWebSavePossible()) {
    safeTimeout(function() {callback([]);}, 0);
    return;
  }
  var xhr = findXhr();
  var gameName = window.remoteStoreName || window.storeName;
  xhr.open("GET", webSaveUrl + "?email="+email+"&game="+gameName, true);
  var done = false;
  xhr.onreadystatechange = function() {
    if (done) return;
    if (xhr.readyState != 4) return;
    done = true;
    if (xhr.status != 200) {
      if (window.console) console.log("Couldn't load remote saves. " + xhr.status + ": " + xhr.responseText);
      safeCall(null, function() {callback(null);});
    } else {
      var result = xhr.responseText;
      result = jsonParse(result);
      var remoteSaveList = [];
      for (var i = 0; i < result.length; i++) {
        if (!result[i]) continue;
        var save = result[i].json;
        if (!save) continue;
        save.timestamp = result[i].timestamp;
        remoteSaveList.push(save);
      }
      safeCall(null, function() {callback(remoteSaveList);});
    }
  };
  xhr.send();
}

function mergeRemoteSaves(remoteSaveList, recordDirty, callback) {
  if (!isWebSavePossible()) {
    safeTimeout(function() { callback([], 0, []); }, 0);
    return;
  }
  restoreObject(initStore(), "save_list", [], function (localSlotList) {
    fetchSavesFromSlotList(initStore(), localSlotList, 0, [], function(localSaveList) {
      var localSlotMap = {};
      for (var i = 0; i < localSlotList.length; i++) {
        localSlotMap[localSlotList[i]] = i;
      }
      var remoteSlotMap = {};
      for (i = 0; i < remoteSaveList.length; i++) {
        remoteSlotMap["save"+remoteSaveList[i].timestamp] = 1;
      }
      var newRemoteSaves = 0;
      for (i = 0; i < remoteSaveList.length; i++) {
        var remoteSave = remoteSaveList[i];
        var slot = "save"+remoteSave.timestamp;
        saveCookie(null, slot, remoteSave.stats, remoteSave.temps, remoteSave.lineNum, remoteSave.indent, remoteSave.deleted, remoteSave.undeleted);
        if (typeof localSlotMap[slot] !== 'undefined') {
          localSaveList[localSlotMap[slot]] = remoteSave;
        } else {
          localSlotList.push(slot);
          localSaveList.push(remoteSave);
          newRemoteSaves++;
        }
      }

      var dirtySaveList = [];
      for (i = 0; i < localSlotList.length; i++) {
        if (!remoteSlotMap[localSlotList[i]]) {
          dirtySaveList.push(localSlotList[i]);
        }
      }

      if (recordDirty) {
        window.store.set("dirty_save_list", toJson(dirtySaveList), finale);
      } else {
        finale();
      }

      function finale() {
        if (newRemoteSaves) {
          window.store.set("save_list", toJson(localSlotList), function() {
            safeCall(null, function() { callback(localSaveList, newRemoteSaves, dirtySaveList); });
          });
        } else {
          safeCall(null, function() {callback(localSaveList, newRemoteSaves, dirtySaveList);});
        }
      }
    });
  });
}

function getAppId() {
  if (window.isIosApp) {
    var appBanner = document.querySelector("meta[name=apple-itunes-app]");
    return /app-id=(\d+)/.exec(appBanner.getAttribute("content"))[1];
  } else if (window.isAndroidApp) {
    var androidLink = document.getElementById('androidLink');
    return /id=([\.\w]+)/.exec(androidLink.href)[1];
  }
}

function submitReceipts(receipts, callback) {
  console.log("submitReceipts: " + JSON.stringify(receipts));
  if (!callback) callback = function(error) {
    if (window.transferPurchaseCallback) {
      window.transferPurchaseCallback(error || "done");
    }
  };
  var appId = receipts.appId;
  var count = 0;
  var error;
  function submitCallback(product) {
    return function submitCallback(ok, response) {
      if (!ok) {
        console.log("failed: " + product + " " + JSON.stringify(response));
        if (!error) {
          var match = /^receipt transaction already processed: (\d+) current login (\d+)$/.exec(response.error);
          if (match) {
            callback("409-" + match[1] + "-" + match[2]);
          } else {
            callback("" + response.status + "r");
          }
        }
        error = true;
      }
      if (error) return;
      count--;
      if (!count) {
        if (!receipts.avoidOverrides) cacheKnownPurchases(response);
        callback();
      }
    }
  }

  if (window.isAndroidApp) {
    window.store.get("login", function(ok, loginId) {
      loginId = loginId || 0;
      var platform = window.isAmazonAndroidApp ? 'amazon' : 'google';
      if (receipts.prePurchased && platform === 'google') {
        for (var i = 0; i < receipts.prePurchased.length; i++) {
          var product = receipts.prePurchased[i];
          count++;
          xhrAuthRequest("POST", "submit-device-receipt", submitCallback(product),
            'platform', platform,
            'company', receipts.company,
            'game_id', window.storeName,
            'app_package', appId,
            'product_id', product,
            'signature', encodeURIComponent(receipts.signature),
            'receipt', encodeURIComponent(receipts.signedData),
            'login_id', loginId
          );
        }
      }
      if (receipts.iaps) {
        for (var product in receipts.iaps) {
          count++;
          xhrAuthRequest("POST", "submit-device-receipt", submitCallback(product),
            'platform', platform,
            'company', receipts.company,
            'game_id', window.storeName,
            'app_package', appId,
            'product_id', product,
            'receipt', encodeURIComponent(receipts.iaps[product]),
            'login_id', loginId
          );
        }
      }
      if (!count) safeTimeout(function () { callback(); }, 0);
    });
  } else {
    callback("error");
  }
}

function delayBreakStart(callback) {
  var nowInSeconds = Math.floor(new Date().getTime() / 1000);
  if (!initStore()) {
    safeTimeout(function() {callback(nowInSeconds);}, 0);
    return;
  }
  window.store.get("delayBreakStart", function(ok, value) {
    var valueNum = value*1;
    safeCall(null, function() {
      if (ok && value && !isNaN(valueNum)) {
        callback(valueNum);
      } else {
        window.store.set("delayBreakStart", nowInSeconds);
        callback(nowInSeconds);
      }
    });
  });
}

function delayBreakEnd() {
  if (initStore()) window.store.remove("delayBreakStart");
}

function initStore() {
  if (!window.storeName) return false;
  if (window.store) return window.store;
  try {
    window.store = new Persist.Store(window.storeName);
  } catch (e) {
    console.error(e);
  }
  return window.store;
}
function loadAndRestoreGame(slot, forcedScene, forcedStats, forcedTemps) {
  function valueLoaded(ok, value) {
    safeCall(null, function() {
      var state = null;
      if (ok && value && ""+value) {
        //console.log("successfully loaded slot " + slot);
        state = jsonParse(value);
      } else if (slot == "backup") {
        console.log("loadAndRestoreGame couldn't find backup");
        if (typeof alertify !== "undefined") alertify.log("Failed restarting chapter. Restarting the game from scratch.");
        return restartGame();
      }
      restoreGame(state, forcedScene, false, forcedStats, forcedTemps);
    });
  }
  if (!slot) slot = "";
  if (_global.pseudoSave && pseudoSave[slot]) return valueLoaded(true, pseudoSave[slot]);
  if (!initStore()) return restoreGame(null, forcedScene);
  window.store.get("state"+slot, valueLoaded);
}

function isStateValid(state) {
  if (!state) return false;
  if (!state.stats) return false;
  if (!state.stats.sceneName) return false;
  return true;
}

function restartGame(shouldPrompt) {
  if (_global.blockRestart) {
    asyncAlert("Please wait until the timer has run out.");
    return;
  }
  function actuallyRestart(result) {
    if (!result) return;
    delayBreakEnd();
    submitAnyDirtySaves();
    clearCookie(function() {}, 'temp');
    clearCookie(function() {
      _global.nav.resetStats(_global.stats);
      clearScreen(restoreGame);
    }, "");
  }
  if (shouldPrompt) {
    asyncConfirm("Start over from the beginning?", actuallyRestart);
  } else {
    actuallyRestart(true);
  }
}

function restoreGame(state, forcedScene, userRestored, forcedStats, forcedTemps) {
    var scene;
    var secondaryMode = null;
    var saveSlot = "";
    var forcedSceneLabel = null;
    if (/\|/.test(forcedScene)) {
      var parts = forcedScene.split("|");
      forcedScene = parts[0];
      forcedSceneLabel = parts[1];
    }
    if (forcedScene == "choicescript_stats") {
      secondaryMode = "stats";
      saveSlot = "temp";
    } else if (forcedScene == "choicescript_upgrade") {
      secondaryMode = "upgrade";
      saveSlot = "temp";
    }
    if (!isStateValid(state)) {
        var startupScene = forcedScene ? forcedScene : _global.nav.getStartupScene();
        scene = new Scene(startupScene, _global.stats, _global.nav, {debugMode:_global.debug, secondaryMode:secondaryMode, saveSlot:saveSlot});
    } else {
      if (forcedScene) state.stats.sceneName = forcedScene;
      if (forcedStats) {
        for (var stat in forcedStats) {
          state.stats[stat] = forcedStats[stat];
        }
      }
      if (forcedTemps) {
        for (var temp in forcedTemps) {
          state.temps[temp] = forcedTemps[temp];
        }
      }
      _global.stats = state.stats;
      // Someday, inflate the navigator using the state object
      scene = new Scene(state.stats.sceneName, state.stats, _global.nav, {debugMode:state.debug || _global.debug, secondaryMode:secondaryMode, saveSlot:saveSlot});
      if (!forcedScene) {
        scene.temps = state.temps;
        scene.lineNum = state.lineNum;
        scene.indent = state.indent;
      }
      if (userRestored) {
        scene.temps.choice_user_restored = true;
      }
    }
    if (forcedSceneLabel !== null) {
      scene.targetLabel = {label:forcedSceneLabel, origin:"url", originLine:0}
    }
    safeCall(scene, scene.execute);
}

function redirectScene(sceneName, label, originLine) {
  var scene = new Scene(sceneName, window.stats, window.nav, {debugMode:window.debug});
  if (label) scene.targetLabel = {label:label, origin:"choicescript_stats", originLine:originLine};
  scene.redirectingFromStats = true;
  clearScreen(function() {scene.execute();});
}

tempStatWrites = {};

function transferTempStatWrites() {
  if (!_global.isIosApp) return;
  callIos("transferwrites", JSON.stringify(tempStatWrites));
}

function getCookieByName(cookieName, ck) {
    if (!ck) ck = window.document.cookie;
    if (!ck) return null;
    var ckPairs = ck.split(/;/);
    for (var i = 0; i < ckPairs.length; i++) {
        var ckPair = trim(ckPairs[i]);
        var ckNameValue = ckPair.split(/=/);
        var ckName = decodeURIComponent(ckNameValue[0]);
        if (ckName === cookieName) {
            return decodeURIComponent(ckNameValue[1]);
        }
    }
    return null;
}

function parseQueryString(str) {
  if (!str) return null;
  str = String(str).substring(1);
  if (!str) return null;
  var map = {};
  var pairs = str.split("&");
  var i = pairs.length;
  while (i--) {
    var pair = pairs[i];
    var parts = pair.split("=");
    map[parts[0]] = parts[1];
  }
  return map;
}

function trim(str) {
    if (str === null || str === undefined) return null;
    var result = str.replace(/^\s+/g, "");
    // strip leading
    return result.replace(/\s+$/g, "");
    // strip trailing
}


function findOptimalDomain(docDomain) {
    if (!docDomain) docDomain = document.domain;
    // localhost and 127.0.0.1 will cause cookie not to be set; just omit them, it works fine
    if (docDomain == "localhost" || docDomain == "127.0.0.1") return null;
    // ip address
    if (/^\d+\.\d+\.\d+\.\d+$/.test(docDomain)) return null;
    // dotcom
    var result = docDomain.match(/(\w+\.\w{3}$)/);
    if (result) return result[1];
    return null;
}

function num(x, line, sceneName) {
    if (!line) line = "UNKNOWN";
    errorInfo = "line "+line;
    if (sceneName) errorInfo = sceneName + " " + errorInfo;
    var x_num = parseFloat(x);
    if (isNaN(x_num)) throw new Error(errorInfo+": Not a number: " + x);
    if (!isFinite(x_num)) throw new Error(errorInfo+": Not finite " + x);
    return x_num;
}

function bool(x, line, sceneName) {
  if (!line) line = "UNKNOWN";
  if ("boolean" == typeof x) {
    return x;
  } else if ("true" === x) {
    return true;
  } else if ("false" === x) {
    return false;
  }
  errorInfo = "line "+line;
  if (sceneName) errorInfo = sceneName + " " + errorInfo;
  throw new Error(errorInfo+": Neither true nor false: " + x);
}

function findXhr() {
  var ieFile = isIE && isFile;
  if (window.XMLHttpRequest && !ieFile) return new window.XMLHttpRequest();
  var ids = ['Msxml2.XMLHTTP', 'Microsoft.XMLHTTP', 'Msxml2.XMLHTTP.4.0'];
  for (var i = 0; i < 3; i++) {
    try {
      return new ActiveXObject(ids[i]);
    } catch (e) {}
  }
  throw new Error("Couldn't create XHR object");
}

    crcTable = "00000000 77073096 EE0E612C 990951BA 076DC419 706AF48F E963A535 9E6495A3 0EDB8832 79DCB8A4 E0D5E91E 97D2D988 09B64C2B 7EB17CBD E7B82D07 90BF1D91 1DB71064 6AB020F2 F3B97148 84BE41DE 1ADAD47D 6DDDE4EB F4D4B551 83D385C7 136C9856 646BA8C0 FD62F97A 8A65C9EC 14015C4F 63066CD9 FA0F3D63 8D080DF5 3B6E20C8 4C69105E D56041E4 A2677172 3C03E4D1 4B04D447 D20D85FD A50AB56B 35B5A8FA 42B2986C DBBBC9D6 ACBCF940 32D86CE3 45DF5C75 DCD60DCF ABD13D59 26D930AC 51DE003A C8D75180 BFD06116 21B4F4B5 56B3C423 CFBA9599 B8BDA50F 2802B89E 5F058808 C60CD9B2 B10BE924 2F6F7C87 58684C11 C1611DAB B6662D3D 76DC4190 01DB7106 98D220BC EFD5102A 71B18589 06B6B51F 9FBFE4A5 E8B8D433 7807C9A2 0F00F934 9609A88E E10E9818 7F6A0DBB 086D3D2D 91646C97 E6635C01 6B6B51F4 1C6C6162 856530D8 F262004E 6C0695ED 1B01A57B 8208F4C1 F50FC457 65B0D9C6 12B7E950 8BBEB8EA FCB9887C 62DD1DDF 15DA2D49 8CD37CF3 FBD44C65 4DB26158 3AB551CE A3BC0074 D4BB30E2 4ADFA541 3DD895D7 A4D1C46D D3D6F4FB 4369E96A 346ED9FC AD678846 DA60B8D0 44042D73 33031DE5 AA0A4C5F DD0D7CC9 5005713C 270241AA BE0B1010 C90C2086 5768B525 206F85B3 B966D409 CE61E49F 5EDEF90E 29D9C998 B0D09822 C7D7A8B4 59B33D17 2EB40D81 B7BD5C3B C0BA6CAD EDB88320 9ABFB3B6 03B6E20C 74B1D29A EAD54739 9DD277AF 04DB2615 73DC1683 E3630B12 94643B84 0D6D6A3E 7A6A5AA8 E40ECF0B 9309FF9D 0A00AE27 7D079EB1 F00F9344 8708A3D2 1E01F268 6906C2FE F762575D 806567CB 196C3671 6E6B06E7 FED41B76 89D32BE0 10DA7A5A 67DD4ACC F9B9DF6F 8EBEEFF9 17B7BE43 60B08ED5 D6D6A3E8 A1D1937E 38D8C2C4 4FDFF252 D1BB67F1 A6BC5767 3FB506DD 48B2364B D80D2BDA AF0A1B4C 36034AF6 41047A60 DF60EFC3 A867DF55 316E8EEF 4669BE79 CB61B38C BC66831A 256FD2A0 5268E236 CC0C7795 BB0B4703 220216B9 5505262F C5BA3BBE B2BD0B28 2BB45A92 5CB36A04 C2D7FFA7 B5D0CF31 2CD99E8B 5BDEAE1D 9B64C2B0 EC63F226 756AA39C 026D930A 9C0906A9 EB0E363F 72076785 05005713 95BF4A82 E2B87A14 7BB12BAE 0CB61B38 92D28E9B E5D5BE0D 7CDCEFB7 0BDBDF21 86D3D2D4 F1D4E242 68DDB3F8 1FDA836E 81BE16CD F6B9265B 6FB077E1 18B74777 88085AE6 FF0F6A70 66063BCA 11010B5C 8F659EFF F862AE69 616BFFD3 166CCF45 A00AE278 D70DD2EE 4E048354 3903B3C2 A7672661 D06016F7 4969474D 3E6E77DB AED16A4A D9D65ADC 40DF0B66 37D83BF0 A9BCAE53 DEBB9EC5 47B2CF7F 30B5FFE9 BDBDF21C CABAC28A 53B39330 24B4A3A6 BAD03605 CDD70693 54DE5729 23D967BF B3667A2E C4614AB8 5D681B02 2A6F2B94 B40BBE37 C30C8EA1 5A05DF1B 2D02EF8D";

    /* Number */
    function crc32( /* String */ str, /* Number */ crc ) {
        if( !crc ) crc = 0;
        var n = 0; //a number between 0 and 255 
        var x = 0; //an hex number 

        crc = crc ^ (-1);
        for( var i = 0, iTop = str.length; i < iTop; i++ ) {
            n = ( crc ^ str.charCodeAt( i ) ) & 0xFF;
            x = "0x" + crcTable.substr( n * 9, 8 );
            crc = ( crc >>> 8 ) ^ x;
        }
        return crc ^ (-1);
    }

function simpleDateFormat(date) {
  var day = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][date.getDay()];
  var month = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][date.getMonth()];
  var minutes = date.getMinutes();
  if (minutes < 10) minutes = "0" + ("" + minutes);
  var oneYearInMillis = 1000 * 60 * 60 * 24 * 365;
  var millisAgo = new Date().getTime() - date.getTime();
  var yearString = ""
  if (millisAgo > oneYearInMillis) {
    yearString = ", " + date.getFullYear();
  }
  return day + ", " + month + " " + date.getDate() + yearString;
}

function simpleDateTimeFormat(date) {
  var minutes = date.getMinutes();
  if (minutes < 10) minutes = "0" + ("" + minutes);
  return simpleDateFormat(date) + ", " + date.getHours() + ":" + minutes;
}

function jsonParse(str) {
  if (typeof JSON != "undefined") {
    try {
      return JSON.parse(str);
    } catch (e) {
      // try to handle unquoted keys
      try {
        return eval('('+str+')');
      } catch (e2) {
        // that might have failed because eval is forbidden
        try {
          eval("1");
        } catch (e3) {
          // eval forbidden; let's try a hack to fix unquoted keys
          var str2 = (str+"").replace(/([,\{])\s*(\w+)\s*\:/g, '$1"$2":');
          try {
            return JSON.parse(str2);
          } catch (e4) {}
        }
        // at this point, just report a clear error
        return JSON.parse(str);
      }
    }
  } else {
    return eval('('+str+')');
  }
}

function cefQuerySimple(method) {
  cefQuery({
    request:method,
    onSuccess: function(response) {console.log(method + " success");},
    onFailure: function(error_code, error_message) {console.error(method + " error: " + error_message);}
  });
}

shortMonthStrings = [null, "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];

// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/parse
// "Given a date string of "March 7, 2014", parse() assumes a local time zone, but given an
// ISO format such as "2014-03-07" it will assume a time zone of UTC for ES5 or local for
// ECMAScript 2015."
function parseDateStringInCurrentTimezone(YYYY_MM_DD, line) {
  var result = /^(\d{4})-(\d{2})-(\d{2})$/.exec(YYYY_MM_DD);
  if (!result) throw new Error("line "+line+": invalid date string " + YYYY_MM_DD);
  var fullYear = result[1];
  var oneBasedMonthNumber = parseInt(result[2],10);
  var dayOfMonth = parseInt(result[3],10);
  var shortMonthString = shortMonthStrings[oneBasedMonthNumber];
  return new Date(shortMonthString + " " + dayOfMonth + ", " + fullYear);
}

function matchBracket(line, brackets, startIndex) {
  var openBracket = brackets[0];
  var closeBracket = brackets[1];
  var brackets = 0;
  for (var i = startIndex; i < line.length; i++) {
    var c = line.charAt(i);
    if (c === openBracket) {
      brackets++;
    } else if (c === closeBracket) {
      if (brackets) {
        brackets--;
      } else {
        return i;
      }
    }
  }
  return -1;
}

function isStoreSceneCacheRequired() {
  if (!(initStore() &&
    _global.purchases &&
    _global.checkPurchase &&
    _global.hashes &&
    !(_global.isOmnibusApp && _global.isIosApp) &&
    hashes.scenes
  )) return false;
  var empty = true;
  for (var scene in purchases) {
    if (/^fake:/.test(scene)) continue;
    empty = false;
    break;
  }
  return !empty;
}

function updateSinglePaidSceneCache(sceneName, callback) {
  sceneName = sceneName.replace(/ /g, "_");
  var fileName = sceneName + ".txt.json";
  if (initStore() && _global.hashes && hashes.scenes && hashes.scenes[fileName]) {
    function actualRequest(receiptsSent) {
      var xhr = new XMLHttpRequest();
      var canonical = document.querySelector("link[rel=canonical]");
      var canonicalHref = canonical && canonical.getAttribute("href");
      if (window.beta) {
        canonicalHref = canonicalHref.replace('https://www.choiceofgames.com/', 'https://www.choiceofgames.com/beta/');
      }
      var url = canonicalHref + "scenes/" + fileName + "?hash="+hashes.scenes[fileName];
      xhr.open("GET", url);
      xhr.withCredentials = true;
      xhr.onload = function() {
        var error;
        if (xhr.status !== 200) {
          try {
            error = JSON.parse(xhr.responseText).error;
          } catch (e) {}
        }
        if (xhr.status === 404) {
          try {
            if (error === "hash doesn't match") {
              return awaitAppUpdate(function() {
                callback("strange");
              })
            }
          } catch (e) {
            if (window.console) console.error(e, e.stack);
          }
        }
        if (xhr.status == 403 && _global.isOmnibusApp && _global.isAndroidApp) {
          if (!receiptsSent) {
            window.receiptRequestCallback = function(receipts) {
              window.receiptRequestCallback = null;
              submitReceipts(receipts, function(error) {
                if (error) {
                  return callback(error);
                } else {
                  actualRequest("receiptsSent");
                }
              });
            }
            androidBilling.requestReceipts();
            return;
          } else if (error === "not registered" || error == "not purchased") {
            return callback(error);
          }
        } else if (xhr.status !== 200) {
          return callback(xhr.status);
        }
        var result;
        try {
          result = jsonParse(xhr.responseText);
        } catch (e) {
          if (window.console) console.error(e, e.stack);
        }
        var ok = result && result.crc && result.lines && result.labels;
        if (!ok) return callback("network");
        window.store.set("cache_scene_"+sceneName, xhr.responseText, function() {
          window.store.set("cache_scene_hash_"+sceneName, hashes.scenes[fileName], function() {
            callback(null, result);
          });
        });
      };
      xhr.onerror = function() {
        callback("network");
      }
      console.log("updateSinglePaidSceneCache " + url);
      xhr.send();
    }
    actualRequest();
  }
}

function updateAllPaidSceneCaches(receiptsSent) {
  if (!isStoreSceneCacheRequired()) {
    if (window.isOmnibusApp && window.isAndroidApp) {
      window.receiptRequestCallback = submitReceipts;
      androidBilling.requestReceipts();
    }
    return;
  }
  if (_global.isOmnibusApp && _global.isAndroidApp && !receiptsSent) {
    window.receiptRequestCallback = function(receipts) {
      submitReceipts(receipts, function() {updateAllPaidSceneCaches("receiptsSent");});
    };
    androidBilling.requestReceipts();
    return;
  }
  var flipped = {};
  for (var scene in purchases) {
    if (/^fake:/.test(scene)) continue;
    scene = scene.replace(/ /g, "_");
    if (!flipped[purchases[scene]]) flipped[purchases[scene]] = [];
    flipped[purchases[scene]].push(scene);
  }
  var products = [];
  for (var product in flipped) {
    products.push(product);
  }
  if (!products.length) return;
  checkPurchase(products.join(" "), function(ok, result) {
    if (!ok || !result) return;
    var sceneList = [];
    var push = Array.prototype.push;
    for (var product in flipped) {
      if (result[product]) {
        push.apply(sceneList, flipped[product]);
      }
    }
    if (!sceneList.length) return;
    for (var i = 0; i < sceneList.length; i++) {
      (function(i) {
        var scene = sceneList[i];
        var fileName = scene + ".txt.json";
        window.store.get("cache_scene_hash_"+scene, function(ok, result) {
          if (!ok || result !== hashes.scenes[fileName]) {
            updateSinglePaidSceneCache(scene, function() {});
          }
        });
      })(i);
    }
  })
}

function checkForAppUpdates() {
  if (navigator.serviceWorker) {
    navigator.serviceWorker.getRegistration().then(function(reg) {
      if (reg) reg.update();
    });
  }
}

function refreshIfAppUpdateReady() {
  if (navigator.serviceWorker) {
    if (window.controllerchanged) {
      return window.location.reload();
    }
    navigator.serviceWorker.getRegistration().then(function(reg) {
      if (reg.waiting) {
        reg.waiting.postMessage('skipWaiting');
        return window.location.reload();
      }
    });
  }
}

function awaitAppUpdate(callback) {

  var serviceWorkerExists = (navigator.serviceWorker && navigator.serviceWorker.controller);
  if (!serviceWorkerExists) return callback();

  var calledBack = false;
  var maybeCallback = function() {
    if (calledBack) return;
    calledBack = true;
    callback();
  }

  if (window.controllerchanged) return maybeCallback();
  setTimeout(maybeCallback, 10000);
  navigator.serviceWorker.getRegistration().then(function(reg) {
    if (!reg) return maybeCallback();
    if (reg.waiting) {
      reg.waiting.postMessage('skipWaiting');
      return maybeCallback();
    }
    var watchStateChange = function() {
      if (this.state == 'installed') {
        if (reg.waiting) reg.waiting.postMessage('skipWaiting');
        maybeCallback();
      }
    };
    if (reg.installing) reg.installing.addEventListener('statechange', watchStateChange);
    reg.update();
    reg.addEventListener('updatefound', function() {
      reg.installing.addEventListener('statechange', watchStateChange);
    })
  });
}

function remoteConfig(variable, callback) {
  if (!_global.isOmnibusApp) {
    safeTimeout(function() {callback(null);}, 0);
    return;
  }
  if (_global.isIosApp) {
    var nonce = "remoteConfig" + variable + (+new Date);
    window[nonce] = function(value) {
      delete window[nonce];
      callback(value);
    }
    callIos("remoteconfig", variable + " " + nonce);
  } else {
    var result = (_global.androidRemoteConfig && androidRemoteConfig.remoteConfig(variable)) || null;
    return safeTimeout(function() {callback(result);}, 0);
  }
}
/*
 * Copyright 2010 by Dan Fabulich.
 *
 * Dan Fabulich licenses this file to you under the
 * ChoiceScript License, Version 1.0 (the "License"); you may
 * not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *  http://www.choiceofgames.com/LICENSE-1.0.txt
 *
 * See the License for the specific language governing
 * permissions and limitations under the License.
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
 * either express or implied.
 */

;(function() {
  var lastTime = 0;
  if (!window.requestAnimationFrame)
    window.requestAnimationFrame = function(callback, element) {
        var currTime = new Date().getTime();
        var timeToCall = Math.max(0, 16 - (currTime - lastTime));
        var id = window.setTimeout(function() { callback(currTime + timeToCall); },
          timeToCall);
        lastTime = currTime + timeToCall;
        return id;
    };
})();

function printx(msg, parent) {
    if (msg === null || msg === undefined || msg === "") return;
    if (!parent) parent = document.getElementById('text');
    if (msg == " ") {
      // IE7 doesn't like innerHTML that's nothing but " "
      parent.appendChild(document.createTextNode(" "));
      return;
    }
    msg = replaceBbCode(msg);
    var frag = document.createDocumentFragment();
    temp = document.createElement('div');
    temp.innerHTML = msg;
    while (temp.firstChild) {
        frag.appendChild(temp.firstChild);
    }
    parent.appendChild(frag);
}

function replaceBbCode(msg) {
  return msg = String(msg).replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;')
      .replace(/\[url\=(.*?)\]/g, '<a href="$1">')
      .replace(/\[\/url\]/g, '</a>')
      .replace(/\[n\/\]/g, '<br>')
      .replace(/\[c\/\]/g, '')
      .replace(/\[b\]/g, '<b>')
      .replace(/\[\/b\]/g, '</b>')
      .replace(/\[i\]/g, '<i>')
      .replace(/\[\/i\]/g, '</i>')
      .replace(/\u2022([\s\S]*)/, '<ul>•$1</ul>')
      .replace(/\u2022([^\u2022]*)/g, '<li>$1</li>')
}

function println(msg, parent) {
    if (!parent) parent = document.getElementById('text');
    printx(msg, parent);
    var br = window.document.createElement("br");
    parent.appendChild(br);
}

function printParagraph(msg, parent) {
  if (msg === null || msg === undefined || msg === "") return;
  if (!parent) parent = document.getElementById('text');
  msg = replaceBbCode(msg);
  p = document.createElement('p');
  p.innerHTML = msg;
  parent.appendChild(p);
  return p;
}

function showStats() {
    if (document.getElementById('loading')) return;
    var button = document.getElementById("statsButton");
    if (button && button.innerHTML == "Return to the Game") {
      return clearScreen(function() {
        setButtonTitles();
        loadAndRestoreGame();
      });
    }
    var currentScene = window.stats.scene;
    var scene = new Scene("choicescript_stats", window.stats, this.nav, {secondaryMode:"stats", saveSlot:"temp"});
    clearScreen(function() {
      setButtonTitles();
      scene.execute();
    })
}

function redirectFromStats(scene, label, originLine, callback) {
  if (window.isIosApp) {
    if (!label) label = "";
    callIos("redirectfromstats", scene + " " +label  + " " + originLine);
  } else if (window.isAndroidApp) {
    statsMode.redirectFromStats(scene, label || "", originLine);
  } else {
    safeTimeout(callback, 0);
  }
}

function returnFromStats() {
  if (window.isIosApp && !window.isIPad) {
    callIos("returntogame");
  } else if (window.isAndroidApp && window.statsMode.get()) {
    statsMode.returnToGame();
  } else {
    clearScreen(loadAndRestoreGame);
  }
}

function restoreCheckpointFromStats(callback) {
  safeTimeout(callback, 0);
}

function showAchievements(hideNextButton) {
  if (document.getElementById('loading')) return;
  var button = document.getElementById("achievementsButton");
  if (!button) return;
  if (button.innerHTML == "Return to the Game") {
    return clearScreen(function() {
      setButtonTitles();
      loadAndRestoreGame();
    });
  }
  clearScreen(function() {
    setButtonTitles();
    var button = document.getElementById("achievementsButton");
    button.innerHTML = "Return to the Game";
    checkAchievements(function() {
      printAchievements(document.getElementById("text"));
      if (!hideNextButton) printButton("Next", main, false, function() {
        clearScreen(function() {
          setButtonTitles();
          loadAndRestoreGame();
        });
      });
      curl();
    });
  });
}

function showMenu() {
  if (document.getElementById('loading')) return;
  var button = document.getElementById("menuButton");
  if (!button) return;
  if (button.innerHTML == "Return to the Game") {
    return clearScreen(function() {
      setButtonTitles();
      loadAndRestoreGame();
    });
  }
  function menu() {
    setButtonTitles();
    var button = document.getElementById("menuButton");
    button.innerHTML = "Return to the Game";
    options = [{name:"Return to the game.", group:"choice", resume:true}];
    if (window.isSteamworks) {
      options.push(
        {name:"Quit the game.", group:"choice", quit:true}
      )
    }
    if (nav.achievementList.length) options.push(
      { name: "View achievements.", group: "choice", achievements: true }
    );
    options.push(
      {name:"Restart the game.", group:"choice", restart:true},
      {name:"Change settings.", group:"choice", settings:true}
      /*{name:"Play more games like this.", group:"choice", moreGames:true},
      {name:"Email us at " + getSupportEmail() + ".", group:"choice", contactUs:true},
      {name:"Share this game with friends.", group:"choice", share:true},
      {name:"Email me when new games are available.", group:"choice", subscribe:true},
      {name:"Play more games like this.", group:"choice", moreGames:true}*/
    );
    if (false) {
      options.push(
        { name: "Email us at " + getSupportEmail() + ".", group: "choice", contactUs: true },
        { name: "Report a bug.", group: "choice", reportBug: true },
        { name: "Share this game with friends.", group: "choice", share: true }
      );
    }
    /*options.push(
      {name:"Email me when new games are available.", group:"choice", subscribe:true},
      {name:"Show keyboard shortcuts.", group:"choice", shortcuts:true}
    )*/
    if (document.getElementById("aboutLink")) {
      options.push({name:"View the credits.", group:"choice", credits:true});
    }
    if (parent && parent.cside) {
      options.push({name:"Clear game data.", group:"choice", clearData:true});
    }
    var logoutLink = document.getElementById("logout");
    if (logoutLink && logoutLink.style.display !== "none") {
      // options.push({ name: "Sign out.", group: "choice", logout: true });
    }
    printOptions([""], options, function(option) {
      if (option.resume) {
        return clearScreen(function() {
          setButtonTitles();
          loadAndRestoreGame();
        });
      } else if (option.quit) {
        require('electron').ipcRenderer.invoke('quit');
      } else if (option.achievements) {
        return showAchievements();
      } else if (option.restart) {
        if (_global.blockRestart) {
          asyncAlert("Please wait until the timer has run out.");
          return;
        }
        return clearScreen(function() {
          var text = document.getElementById('text');
          text.innerHTML = "Start over from the beginning?";
          var options = [
            {name: "Restart the game.", group:"choice", restart: true},
            {name: "Cancel.", group: "choice", restart: false },
          ]
          printOptions([""], options, function(option) {
            if (option.restart) {
              clearScreen(function() {
                setButtonTitles();
                restartGame();
              })
            } else {
              clearScreen(function() {
                setButtonTitles();
                loadAndRestoreGame();
              })
            }
          })
          curl();
        });
      } else if (option.settings) {
        textOptionsMenu({ size: 1, color: 1, animation: window.animationProperty, sliding: window.isMobile });
      } else if (option.credits) {
        absolutizeAboutLink();
        aboutClick();
      } else if (option.moreGames) {
        moreGames();
        curl();
      } else if (option.share) {
        clearScreen(function() {
          printShareLinks(document.getElementById("text"), "now");
          menu();
        });
      } else if (option.subscribe) {
        setButtonTitles();
        subscribeLink();
      } else if (option.contactUs) {
        window.location.href="mailto:"+getSupportEmail();
      } else if (option.reportBug) {
        clearScreen(function() {
          reportBug();
          showMenu();
        });
      } else if (option.shortcuts) {
        var dialog = document.getElementById('keyboardShortcuts');
        if (dialog && dialog.showModal) dialog.showModal();
      } else if (option.logout) {
        clearScreen(function() {
          logout();
          loginDiv();
          printParagraph("You have been signed out.");
          menu();
        })
      }
    });
    // show title and author on menu screen
    // this will be hidden on desktop web, but visible elsewhere
    changeTitle(document.title);
    changeAuthor(document.getElementById("author").innerText.substring(3));
    if (window.isSteamApp) {
      printParagraph("Need help? Email us at " + getSupportEmail() + ".");
    }
    curl();
  }
  clearScreen(menu);
}

function setButtonTitles() {
  var button;
  button = document.getElementById("menuButton");
  if (button) {
    button.innerHTML = "Menu";
  }
  button = document.getElementById("statsButton");
  if (button) {
    button.innerHTML = "Show Stats";
  }
  button = document.getElementById("achievementsButton");
  if (button) {
    if (nav.achievementList.length) {
      button.style.display = "";
      button.innerHTML = "Achievements";
    } else {
      button.style.display = "none";
    }
  }
}


function textOptionsMenu(categories) {
  if (!categories) {
    categories = {size:1, color:1, animation:window.animationProperty, sliding: window.isMobile};
  }
  clearScreen(function() {
    var button = document.getElementById("menuButton");
    if (button) button.innerHTML = "Return to the Game";
    var text = document.getElementById("text");
    var oldZoom = getZoomFactor();
    if (categories.settings) {
      text.innerHTML = "<p>Change the game's settings.</p>";
    } else if (categories.size && categories.color) {
      text.innerHTML = "<p>Change the game's appearance.</p>";
    } else if (categories.size) {
      text.innerHTML = "<p>Make the text bigger or smaller.</p>";
    } else if (categories.color) {
      text.innerHTML = "<p>Change the background color.</p>";
    } else if (categories.animation) {
      text.innerHTML = "<p>Change the animation between pages.</p>";
    } else if (categories.clearData) {
      text.innerHTML = "<p>Clear CSIDE's game data for all games? This means everything: achievements, aesthetic preferences and the rest.</p><p>The game tab will be reset.</p>";
    } else if (categories.sliding) {
      text.innerHTML = "<p>Enable or disable touch slide controls.</p>";
    }
    options = [
      {name:"Return to the game.", group:"choice", resume:true},
    ];
    if (categories.size) {
      options.push(
        {name:"Make the text bigger.", group:"choice", bigger:true},
        {name:"Make the text smaller.", group:"choice", smaller:true}
      );
      if (oldZoom <= 0.5) {
        options[options.length-1].unselectable = true;
      }
      if (oldZoom !== 1) {
        options.push({name:"Reset the text to its original size.", group:"choice", reset:true});
      }
    }
    if (categories.color) options.push(
      {name:"Use a black background.", group:"choice", color:"black"},
      {name:"Use a sepia background.", group:"choice", color:"sepia"},
      {name:"Use a white background.", group:"choice", color:"white"}
    );
    if (categories.animation) {
      if (window.animateEnabled) {
        options.push({ name: "Don't animate between pages.", group: "choice", animation: 2 });
      } else {
        options.push({ name: "Animate between pages.", group: "choice", animation: 1 });
      }
    }
    if (categories.sliding) {
      if (window.slidingEnabled !== false) {
        options.push({ name: "Disable touch slide controls.", group: "choice", sliding: 1 });
      } else {
        options.push({ name: "Enable touch slide controls.", group: "choice", sliding: 2 });
      }
    }
    if (categories.clearData) options.push(
      {name: "Clear data.", group:"choice", clearData:true}
    );
    printOptions([""], options, function(option) {
      if (option.resume) {
        return clearScreen(function() {
          setButtonTitles();
          loadAndRestoreGame();
        });
      } else if (option.color) {
        changeBackgroundColor(option.color);
      } else if (option.reset) {
        setZoomFactor(1);
      } else if (option.animation) {
        window.animateEnabled = option.animation !== 2;
        if (initStore()) store.set("preferredAnimation", parseFloat(option.animation));
      } else if (option.sliding) {
        window.slidingEnabled = option.sliding == 2;
        if (initStore()) store.set("preferredSliding", window.slidingEnabled);
      } else if (option.clearData) {
        if (initStore()) store.store.clear();
        return clearScreen(function() {
          location.reload();
        });
      } else {
        changeFontSize(option.bigger);
      }
      textOptionsMenu(categories);
    })
    curl();
  });
}

function getZoomFactor() {
  if (document.documentElement.style.fontSize === undefined) {
    return window.zoomFactor || 1;
  } else {
    var fontSize = parseFloat(document.documentElement.style.fontSize);
    if (isNaN(fontSize)) fontSize = 100;
    return fontSize / 100;
  }
}

function setZoomFactor(zoomFactor) {
  document.documentElement.style.fontSize = Math.round(100*zoomFactor) + "%";
  window.zoomFactor = zoomFactor;
  if (initStore()) store.set("preferredZoom", String(zoomFactor));
}

function changeFontSize(bigger) {
  var oldZoom = getZoomFactor();
  if (bigger) {
    setZoomFactor(oldZoom + 0.1);
  } else {
    setZoomFactor(oldZoom - 0.1);
  }
}

function changeBackgroundColor(color) {
  if (color === "sepia") {
    document.body.classList.remove("nightmode");
    document.body.classList.remove("whitemode");
  } else if (color === "black") {
    document.body.classList.remove("whitemode");
    document.body.classList.add("nightmode");
  } else if (color === "white") {
    document.body.classList.remove("nightmode");
    document.body.classList.add("whitemode");
  }
  if (initStore()) store.set("preferredBackground", color);
}

function isNightMode() {
  return document.body.classList.contains("nightmode");
}

function spell(num) {
  if (num > 99) return num;
  var smallNumbers = ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen", "seventeen", "eighteen", "nineteen"];
  var tens = ["zero", "ten", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety"];
  if (num < 20) {
    return smallNumbers[num];
  }
  var onesDigit = num % 10;
  if (onesDigit === 0) {
    return tens[num / 10];
  }
  var tensDigit = (num - onesDigit) / 10;
  return tens[tensDigit]+"-"+smallNumbers[onesDigit];
}

function printAchievements(target) {
  var unlockedBuffer = [];
  var lockedBuffer = [];
  var achievedCount = 0, hiddenCount = 0, score = 0, totalScore = 0;
  var totalAchievements = nav.achievementList.length;
  var buffer;
  for (var i = 0; i < totalAchievements; i++) {
    var name = nav.achievementList[i];
    var achievement = nav.achievements[name];
    var points = achievement.points;
    totalScore += points;

    var description;

    if (nav.achieved[name]) {
      achievedCount++;
      score += points;
      buffer = unlockedBuffer;
      description = achievement.earnedDescription;
    } else {
      if (achievement.visible) {
        buffer = lockedBuffer;
        description = achievement.preEarnedDescription;
      } else {
        hiddenCount++;
        continue;
      }
    }

    if (buffer.length) buffer.push("<br>");
    buffer.push("<b>");
    buffer.push(achievement.title.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'));
    buffer.push(":");
    buffer.push("</b> ");
    buffer.push(description.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
      .replace(/\[b\]/g, '<b>')
      .replace(/\[\/b\]/g, '</b>')
      .replace(/\[i\]/g, '<i>')
      .replace(/\[\/i\]/g, '</i>')
    );
    buffer.push(" (");
    buffer.push(points);
    buffer.push(" points)");
  }

  // What if there's exactly one achievement worth exactly one point?
  if (achievedCount === 0) {
    if (hiddenCount === 0) {
      buffer = ["You haven't unlocked any achievements yet. There are "+spell(totalAchievements)+" possible achievements, worth a total of "+totalScore+" points.<p>"];
    } else if (hiddenCount == 1) {
      buffer = ["You haven't unlocked any achievements yet. There are "+spell(totalAchievements)+" possible achievements (including one hidden achievement), worth a total of "+totalScore+" points.<p>"];
    } else if (hiddenCount == totalAchievements) {
      buffer = ["You haven't unlocked any achievements yet. There are "+spell(totalAchievements)+" hidden achievements, worth a total of "+totalScore+" points.<p>"];
    } else {
      buffer = ["You haven't unlocked any achievements yet. There are "+spell(totalAchievements)+" possible achievements (including "+spell(hiddenCount)+" hidden achievements), worth a total of "+totalScore+" points.<p>"];
    }
    if (lockedBuffer.length) {
      buffer.push.apply(buffer, lockedBuffer);
      buffer.push("<p>");
    }
  } else if (score == totalScore) {
    if (totalAchievements == 2) {
      buffer = ["Congratulations! You have unlocked both achievements, earning a total of "+score+" points, a perfect score.<p>"];
    } else {
      buffer = ["Congratulations! You have unlocked all "+spell(totalAchievements)+" achievements, earning a total of "+score+" points, a perfect score.<p>"];
    }
    buffer.push.apply(buffer, unlockedBuffer);
    buffer.push("<p>");
  } else {
    buffer = ["You have unlocked "+spell(achievedCount)+" out of "+spell(totalAchievements)+" possible achievements, earning you a score of "+score+" out of a possible "+totalScore+" points.<p>"];
    buffer.push.apply(buffer, unlockedBuffer);
    var remaining = totalAchievements-achievedCount;
    if (remaining == hiddenCount) {
      if (remaining == 1) {
        buffer.push("<p>There is still one hidden achievement remaining.<p>");
      } else {
        buffer.push("<p>There are still " + spell(remaining) + " hidden achievements remaining.<p>");
      }
    } else if (hiddenCount > 1) {
      buffer.push("<p>There are still "+spell(remaining)+" achievements remaining, including "+spell(hiddenCount)+" hidden achievements.<p>");
    } else if (hiddenCount == 1) {
      buffer.push("<p>There are still "+spell(remaining)+" achievements remaining, including one hidden achievement.<p>");
    } else if (remaining == 1) {
      buffer.push("<p>There is still one achievement remaining.<p>");
    } else {
      buffer.push("<p>There are still "+spell(remaining)+" achievements remaining.<p>");
    }
    if (lockedBuffer.length) {
      buffer.push.apply(buffer, lockedBuffer);
      buffer.push("<p>");
    }
  }

  target.innerHTML = buffer.join("");
}

function asyncAlert(message, callback) {
  if (!callback) callback = function(){};
  if (window.isIosApp) {
    window.alertCallback = callback;
    callIos("alert", message);
  } else if (window.isAndroidApp) {
    setTimeout(function() {
      alert(message);
      if (callback) callback();
    }, 0);
  } else if (window.isWinOldApp) {
    setTimeout(function() {
      window.external.Alert(message);
      if (callback) callback();
    }, 0);
  } else {
    alertify.alert(message, function() {safeCall(null, callback);});
  }
}

function asyncConfirm(message, callback) {
  if (false/*window.isIosApp*/) {
    // TODO asyncConfirm
    window.confirmCallback = callback;
    callIos("confirm", message);
  } else if (window.isAndroidApp) {
    setTimeout(function() {
      var result = confirm(message);
      if (callback) callback(result);
    }, 0);
  } else if (window.isWinOldApp) {
    setTimeout(function() {
      var result = window.external.Confirm(message);
      if (callback) callback(result);
    }, 0);
  } else {
    alertify.confirm(message, function(result) {safeCall(null, callback(result));});
  }
}


function clearScreen(code) {
    var text = document.getElementById("text");
    var container1 = document.getElementById("container1");
    if (!container1) throw new Error("<div id=container1> is missing from index.html");

    if (window.animateEnabled && window.animationProperty && !window.isIosApp && !document.getElementById('container2')) {
      var container2 = document.createElement("div");
      container2.setAttribute("id", "container2");
      container2.classList.add('container');
      document.body.classList.add('frozen');
      container2.style.opacity = 0;


      // get the vertical scroll position as pageYOffset
      // translate up by pageYOffset pixels, then scroll to the top
      // now we're scrolled up, but the viewport *looks* like it has retained its scroll position
      var pageYOffset = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0;
      var extraScroll = 0;
      if (window.isMobile && window.isWeb && window.isAndroid && !/Chrome/.test(navigator.userAgent)) {
        extraScroll = 1; // try to hide url bar
      }
      pageYOffset -= extraScroll;
      container1.style.transform = "translateY(-"+pageYOffset+ "px)";
      container1.style.webkitTransform = "translateY(-"+pageYOffset+ "px)";
      window.scrollTo(0,extraScroll);

      container2.innerHTML = container1.innerHTML;
      [].forEach.call(container1.querySelectorAll('input,button,a,textarea,label'), function(element) {
        element.setAttribute("tabindex", "-1");
        element.removeAttribute("accesskey");
      });

      document.body.insertBefore(container2, container1);
      main = document.getElementById("main");
      main.innerHTML = "";
      text = document.createElement("div");
      text.setAttribute("id", "text");
      main.appendChild(text);
      if (window.isChromeApp) fixChromeLinks();
    } else {
      main = document.getElementById("main");
      main.innerHTML = "";
      text = document.createElement("div");
      text.setAttribute("id", "text");
      main.appendChild(text);

      window.scrollTo(0,1);
    }


    var useAjax = true;
    if (isWeb && window.noAjax) {
      useAjax = false;
    }

    if (useAjax) {
      doneLoading();
      safeCall(null, code);
    } else {
      if (!initStore()) alert("Your browser has disabled cookies; this game requires cookies to work properly.  Please re-enable cookies and refresh this page to continue.");
      startLoading();
      var form = window.document.createElement("form");
      var axn = window.location.protocol + "//" + window.location.host + window.location.pathname;
      form.setAttribute("action", axn);
      form.setAttribute("method", "POST");
      main.appendChild(form);
      form.submit();
    }
}

// in the iOS app, display a page curl animation
function curl() {
  var focusFirst = function() {
    var text = document.getElementById("text");
    if (text && text.firstElementChild) {
      var focusable = text.firstElementChild;
      if (/^img$/i.test(focusable.tagName) && focusable.complete === false) {
        focusable.addEventListener("load", focusFirst);
        return;
      }
      focusable.setAttribute("tabindex", "-1");
      focusable.classList.add("tempfocus");
      focusable.focus();
      focusable.blur();
      requestAnimationFrame(function() {
        focusable.focus();
        requestAnimationFrame(function() {
          focusable.blur();
          focusable.removeAttribute("tabindex");
          focusable.classList.remove("tempfocus");
        });
      });
    }
  }

  // The firstFocus 'jump' breaks CSIDE's UI if the
  // first element is bigger than the iframe's viewport.
  if (parent && parent.cside) { focusFirst = function(){};}

  // TODO force a reflow before curling the page
  var container2 = document.getElementById('container2');
  if (!container2) {
    focusFirst();
    return window.animateEnabled ? callIos("curl") : callIos("unfreeze");
  }

  var container1 = document.getElementById('container1');
  var onContainer1Disappeared = function(e) {
    if (container1.parentElement) container1.parentElement.removeChild(container1);
  };
  var onContainer2Appeared = function(e) {
    document.body.classList.remove('frozen');
    focusFirst();
    container2.removeEventListener('transitionend', onContainer2Appeared);
    container2.removeEventListener('webkitTransitionEnd', onContainer2Appeared);
  };

  if (!window.isIosApp && window.animationProperty) {
    var slideoutStyle = document.getElementById('slideoutStyle');
    if (!slideoutStyle) {
      slideoutStyle = document.createElement("style");
      slideoutStyle.setAttribute("id", "slideoutStyle");
      document.head.appendChild(slideoutStyle);
    }

    var shouldSlide = true;

    var timingFunction = "\n.container { transition-timing-function: ease-in; };";
    if (shouldSlide) timingFunction = "";

    slideoutStyle.innerHTML = "@keyframes containerslideout { "+
      "from { transform: "+container1.style.transform+"; } " +
      "to   { transform: "+container1.style.transform+" translateX(-105%); } }\n"+
      "@-webkit-keyframes containerslideout { "+
      "from { -webkit-transform: "+container1.style.webkitTransform+"; } " +
      "to   { -webkit-transform: "+container1.style.webkitTransform+" translateX(-105%); } }"+
      timingFunction;

    // double rAF so we start after container1 is transformed and scrolled to the top
    // minimizes flicker on iOS
    requestAnimationFrame(function() {
      requestAnimationFrame(function() {
        if (shouldSlide) {
          var fastApple = window.isIPad || window.isIPhone || window.isMacApp;
          var slowAndroid = window.isAndroidApp && /Android 4/.test(navigator.userAgent);
          var useCssAnimations = true; // fastApple || slowAndroid;
          if (useCssAnimations) {
            container1.style[window.animationProperty] = 'containerslideout';
            container2.style[window.animationProperty] = 'containerslidein';
          } else {
            var frames = 0;
            var durationInSeconds = 0.5;
            var framesPerSecond = 60;
            var totalSteps = framesPerSecond * durationInSeconds;
            var oldContainer1Transform = container1.style.transform;
            var rafSlide = function(stamp) {
              var fraction = frames / totalSteps;
              // ease approximation https://github.com/mietek/ease-tween/blob/master/src/index.js
              fraction = 1.0042954579734844 * Math.exp(
                -6.4041738958415664 * Math.exp(
                  -7.2908241330981340 * fraction));
              container1.style.transform = container1.style.webkitTransform =
                oldContainer1Transform + " translateX(-" + (105 * fraction) + "%)";
              container2.style.transform = container2.style.webkitTransform =
                "translateX(" + (100 - 100 * fraction) + "%)";
              if (frames < totalSteps) {
                frames++;
                requestAnimationFrame(rafSlide);
              }
            }
            requestAnimationFrame(rafSlide);
          }
        }
        container1.style.opacity = 0;
        container2.style.opacity = 1;
        container1.addEventListener('transitionend', onContainer1Disappeared);
        container2.addEventListener('transitionend', onContainer2Appeared);
        container1.addEventListener('webkitTransitionEnd', onContainer1Disappeared);
        container2.addEventListener('webkitTransitionEnd', onContainer2Appeared);
      })
    })
  } else {
    onContainer2Appeared();
    onContainer1Disappeared();
    window.animateEnabled ? callIos("curl") : callIos("unfreeze");
  }

  container1.removeAttribute("id");
  container2.setAttribute("id", "container1");
}

function safeSubmit(code) {
    return function safelySubmitted() {
        safeCall(code);
        return false;
    };
}

function startLoading() {
    var loading = document.getElementById('loading');
    if (!loading) {
      safeCall(null, function() {
        loading = document.createElement('div');
        loading.setAttribute("id", "loading");
        loading.innerHTML = (/MSIE [67]/.test(navigator.userAgent)?"":"<img src=\"data:image/gif;base64,R0lGODlhgAAPAPEAAPf08WJhYMvJx2JhYCH/C05FVFNDQVBFMi4wAwEAAAAh/hpDcmVhdGVkIHdpdGggYWpheGxvYWQuaW5mbwAh+QQJCgAAACwAAAAAgAAPAAACo5QvoIC33NKKUtF3Z8RbN/55CEiNonMaJGp1bfiaMQvBtXzTpZuradUDZmY+opA3DK6KwaQTCbU9pVHc1LrDUrfarq765Ya9u+VRzLyO12lwG10yy39zY11Jz9t/6jf5/HfXB8hGWKaHt6eYyDgo6BaH6CgJ+QhnmWWoiVnI6ddJmbkZGkgKujhplNpYafr5OooqGst66Uq7OpjbKmvbW/p7UAAAIfkECQoAAAAsAAAAAIAADwAAArCcP6Ag7bLYa3HSZSG2le/Zgd8TkqODHKWzXkrWaq83i7V5s6cr2f2TMsSGO9lPl+PBisSkcekMJphUZ/OopGGfWug2Jr16x92yj3w247bh6teNXseRbyvc0rbr6/x5Ng0op4YSJDb4JxhI58eliEiYYujYmFi5eEh5OZnXhylp+RiaKQpWeDf5qQk6yprawMno2nq6KlsaSauqS5rLu8cI69k7+ytcvGl6XDtsyzxcAAAh+QQJCgAAACwAAAAAgAAPAAACvpw/oIC3IKIUb8pq6cpacWyBk3htGRk1xqMmZviOcemdc4R2kF3DvfyTtFiqnPGm+yCPQdzy2RQMF9Moc+fDArU0rtMK9SYzVUYxrASrxdc0G00+K8ruOu+9tmf1W06ZfsfXJfiFZ0g4ZvEndxjouPfYFzk4mcIICJkpqUnJWYiYs9jQVpm4edqJ+lkqikDqaZoquwr7OtHqAFerqxpL2xt6yQjKO+t7bGuMu1L8a5zsHI2MtOySVwo9fb0bVQAAIfkECQoAAAAsAAAAAIAADwAAAsucP6CAt9zSErSKZyvOd/KdgZaoeaFpRZKiPi1aKlwnfzBF4jcNzDk/e7EiLuLuhzwqayfmaNnjCCGNYhXqw9qcsWjT++TqxIKp2UhOprXf7PoNrpyvQ3p8fAdu82o+O5w3h2A1+Nfl5geHuLgXhEZVWBeZSMnY1oh5qZnyKOhgiGcJKHqYOSrVmWpHGmpauvl6CkvhaUD4qejaOqvH2+doV7tSqdsrexybvMsZrDrJaqwcvSz9i9qM/Vxs7Qs6/S18a+vNjUx9/v1TAAAh+QQJCgAAACwAAAAAgAAPAAAC0Zw/oIC33NKKUomLxct4c718oPV5nJmhGPWwU9TCYTmfdXp3+aXy+wgQuRRDSCN2/PWAoqVTCSVxilQZ0RqkSXFbXdf3ZWqztnA1eUUbEc9wm8yFe+VguniKPbNf6mbU/ubn9ieUZ6hWJAhIOKbo2Pih58C3l1a5OJiJuflYZidpgHSZCOnZGXc6l3oBWrE2aQnLWYpKq2pbV4h4OIq1eldrigt8i7d73Ns3HLjMKGycHC1L+hxsXXydO9wqOu3brPnLXL3C640sK+6cTaxNflEAACH5BAkKAAAALAAAAACAAA8AAALVnD+ggLfc0opS0SeyFnjn7oGbqJHf4mXXFD2r1bKNyaEpjduhPvLaC5nJEK4YTKhI1ZI334m5g/akJacAiDUGiUOHNUd9ApTgcTN81WaRW++Riy6Tv/S4dQ1vG4ps4NwOaBYlOEVYhYbnplexyJf3ZygGOXkWuWSZuNel+aboV0k5GFo4+qN22of6CMoq2kr6apo6m5fJWCoZm+vKu2Hr6KmqiHtJLKebRhuszNlYZ3ncewh9J9z8u3mLHA0rvetrzYjd2Wz8bB6oNO5MLq6FTp2+bVUAACH5BAkKAAAALAAAAACAAA8AAALanD+ggLfc0opS0XeX2Fy8zn2gp40ieHaZFWHt9LKNO5eo3aUhvisj6RutIDUZgnaEFYnJ4M2Z4210UykQ8BtqY0yHstk1UK+/sdk63i7VYLYX2sOa0HR41S5wi7/vcMWP1FdWJ/dUGIWXxqX3xxi4l0g4GEl5yOHIBwmY2cg1aXkHSjZXmbV4uoba5kkqelbaapo6u0rbN/SZG7trKFv7e6savKTby4voaoVpNAysiXscV4w8fSn8fN1pq1kd2j1qDLK8yYy9/ff9mgwrnv2o7QwvGO1ND049UgAAIfkECQoAAAAsAAAAAIAADwAAAticP6CAt9zSilLRd2d8onvBfV0okp/pZdamNRi7ui3yyoo4Ljio42h+w6kgNiJt5kAaasdYE7D78YKlXpX6GWphxqTT210qK1Cf9XT2SKXbYvv5Bg+jaWD5ekdjU9y4+PsXRuZHRrdnZ5inVidAyCTXF+nGlVhpdjil2OE49hjICVh4qZlpibcDKug5KAlHOWqqR8rWCjl564oLFruIucaYGlz7+XoKe2wsIqxLzMxaxIuILIs6/JyLbZsdGF063Uu6vH2tXc79LZ1MLWS96t4JH/rryzhPWgAAIfkECQoAAAAsAAAAAIAADwAAAtWcP6CAt9zSilLRd2fEe4kPCk8IjqTonZnVsQ33arGLwLV8Kyeqnyb5C60gM2LO6MAlaUukwdbcBUspYFXYcla00KfSywRzv1vpldqzprHFoTv7bsOz5jUaUMer5vL+Mf7Hd5RH6HP2AdiUKLa41Tj1Acmjp0bJFuinKKiZyUhnaBd5OLnzSNbluOnZWQZqeVdIYhqWyop6ezoquTs6O0aLC5wrHErqGnvJibms3LzKLIYMe7xnO/yL7TskLVosqa1aCy3u3FrJbSwbHpy9fr1NfR4fUgAAIfkECQoAAAAsAAAAAIAADwAAAsqcP6CAt9zSilLRd2fEW7cnhKIAjmFpZla3fh7CuS38OrUR04p5Ljzp46kgMqLOaJslkbhbhfkc/lAjqmiIZUFzy2zRe5wGTdYQuKs9N5XrrZPbFu94ZYE6ms5/9cd7/T824vdGyIa3h9inJQfA+DNoCHeomIhWGUcXKFIH6RZZ6Bna6Zg5l8JnSamayto2WtoI+4jqSjvZelt7+URKpmlmKykM2vnqa1r1axdMzPz5LLooO326Owxd7Bzam4x8pZ1t3Szu3VMOdF4AACH5BAkKAAAALAAAAACAAA8AAAK/nD+ggLfc0opS0XdnxFs3/i3CSApPSWZWt4YtAsKe/DqzXRsxDqDj6VNBXENakSdMso66WzNX6fmAKCXRasQil9onM+oziYLc8tWcRW/PbGOYWupG5Tsv3TlXe9/jqj7ftpYWaPdXBzbVF2eId+jYCAn1KKlIApfCSKn5NckZ6bnJpxB2t1kKinoqJCrlRwg4GCs4W/jayUqamaqryruES2b72StsqgvsKlurDEvbvOx8mzgazNxJbD18PN1aUgAAIfkECQoAAAAsAAAAAIAADwAAArKcP6CAt9zSilLRd2fEWzf+ecgjlKaQWZ0asqPowAb4urE9yxXUAqeZ4tWEN2IOtwsqV8YkM/grLXvTYbV4PTZpWGYU9QxTxVZyd4wu975ZZ/qsjsPn2jYpatdx62b+2y8HWMTW5xZoSIcouKjYePeTh7TnqFcpabmFSfhHeemZ+RkJOrp5OHmKKapa+Hiyyokaypo6q1CaGDv6akoLu3DLmLuL28v7CdypW6vsK9vsE1UAACH5BAkKAAAALAAAAACAAA8AAAKjnD+ggLfc0opS0XdnxFs3/nkISI2icxokanVt+JoxC8G1fNOlm6tp1QNmZj6ikDcMrorBpBMJtT2lUdzUusNSt9qurvrlhr275VHMvI7XaXAbXTLLf3NjXUnP23/qN/n8d9cHyEZYpoe3p5jIOCjoFofoKAn5CGeZZaiJWcjp10mZuRkaSAq6OGmU2lhp+vk6iioay3rpSrs6mNsqa9tb+ntQAAA7AAAAAAAAAAAA\">");
        document.body.appendChild(loading);
      });
    }
    callIos('startspinner');
}

function doneLoading() {
    var loading = document.getElementById('loading');
    if (loading) loading.parentNode.removeChild(loading);
    // TODO update header?
    callIos('stopspinner');
    if (window.doneLoadingCallback) {
      var callback = doneLoadingCallback;
      delete window.doneLoadingCallback;
      // long delay to ensure the spinner is fully stopped before continuing
      setTimeout(callback, 10);
    }
}

function setClass(element, classString) {
  element.setAttribute("class", classString);
  element.setAttribute("className", classString);
}

function printFooter() {
  // var footer = document.getElementById('footer');
  // We could put anything we want in the footer here, but perhaps we should avoid it.
  var statsButton = document.getElementById("statsButton");
  if (statsButton) {
    if (window.stats && stats.scene && stats.scene.secondaryMode == "stats") {
      statsButton.innerHTML = "Return to the Game";
    } else {
      statsButton.innerHTML = "Show Stats";
      if (window.isAndroidApp && window.statsMode.get()) {
        showStats();
      }
    }
  }
  curl();
}

// retrieve value of HTML form
function getFormValue(name) {
    var field = document.forms[0][name];
    if (!field) return "";
    // may return either one field or an array of fields
    if (field.checked) return field.value;
    for (var i = 0; i < field.length; i++) {
        var element = field[i];
        if (element.checked) return element.value;
    }
    return null;
}

function printCheckboxes(options, submitButtonNameFunction, callback) {
  var form = document.createElement("form");
  main.appendChild(form);
  var self = this;
  form.action = "#";
  form.onclick = function () {
    var count = 0;
    var checkboxes = optionsDiv.querySelectorAll('input[type=checkbox]');
    for (var i = 0; i < options.length; i++) {
      if (checkboxes[i].checked) count++;
    }
    var button = form.querySelector('input[type=submit]');
    button.value = button.name = submitButtonNameFunction(count);
  }
  form.onsubmit = function () {
    var results = [];
    var checkboxes = optionsDiv.querySelectorAll('input[type=checkbox]');
    for (var i = 0; i < options.length; i++) {
      results[i] = checkboxes[i].checked;
    }
    callback(results);
    return false;
  };
  var checked = false;
  var massSelections = document.createElement("div");
  var selectAll = document.createElement("button");
  selectAll.setAttribute("type", "button");
  massSelections.appendChild(selectAll);
  selectAll.innerHTML = "Select All";
  selectAll.addEventListener("click", function() {
    var checkboxes = optionsDiv.querySelectorAll('input[type=checkbox]');
    for (var i = 0; i < checkboxes.length; i++) {
      checkboxes[i].checked = true;
    }
  });
  massSelections.appendChild(document.createTextNode(' '));
  var selectNone = document.createElement("button");
  selectNone.setAttribute("type", "button");
  massSelections.appendChild(selectNone);
  selectNone.innerHTML = "Select None";
  selectNone.addEventListener("click", function () {
    var checkboxes = optionsDiv.querySelectorAll('input[type=checkbox]');
    for (var i = 0; i < checkboxes.length; i++) {
      checkboxes[i].checked = false;
    }
  });
  form.appendChild(massSelections);
  var optionsDiv = document.createElement("div");
  form.appendChild(optionsDiv);
  setClass(optionsDiv, "choice");
  for (var i = 0; i < options.length; i++) {
    var option = options[i];
    var isLast = (i == options.length - 1);
    printOptionButton(optionsDiv, "checkbox", null, option, i, i, isLast, checked);
  }
  printButton(submitButtonNameFunction(checked ? options.length : 0), form, true);
}

function printOptions(groups, options, callback) {
  var form = document.createElement("form");
  main.appendChild(form);
  var self = this;
  form.action="#";
  form.onsubmit = function() {
      safeCall(self, function() {
        var currentOptions = options;
        var option, group;
        for (var i = 0; i < groups.length; i++) {
            if (i > 0) {
                currentOptions = option.suboptions;
            }
            group = groups[i];
            if (!group) group = "choice";
            var value = getFormValue(group);
            if (value === null || value === undefined) {
              if (groups.length == 1) {
                asyncAlert("Please choose one of the available options first.");
              } else {
                var article = "a";
                if (/^[aeiou].*/i.test(group)) article = "an";
                asyncAlert("Please choose " + article + " " + group + " first.");
              }
              return;
            }
            option = currentOptions[value];
        }

        if (groups.length > 1 && option.unselectable) {
          asyncAlert("Sorry, that combination of choices is not allowed. Please select a different " + groups[groups.length-1] + ".");
          return;
        }
        safeCall(null, function() {callback(option);});
      });
      return false;
  };

  if (!options) throw new Error("undefined options");
  if (!options.length) throw new Error("no options");
  // global num will be used to assign accessKeys to the options
  var globalNum = 1;
  var currentOptions = options;
  for (var groupNum = 0; groupNum < groups.length; groupNum++) {
      var group = groups[groupNum];
      if (group) {
          var textBuilder = ["Select "];
          textBuilder.push(/^[aeiou]/i.test(group)?"an ":"a ");
          textBuilder.push(group);
          textBuilder.push(":");

          var p = document.createElement("p");
          p.appendChild(document.createTextNode(textBuilder.join("")));
          form.appendChild(p);
      }
      var div = document.createElement("div");
      form.appendChild(div);
      setClass(div, "choice");
      var checked = null;
      for (var optionNum = 0; optionNum < currentOptions.length; optionNum++) {
          var option = currentOptions[optionNum];
          if (!checked && !option.unselectable) checked = option;
          var isLast = (optionNum == currentOptions.length - 1);
          printOptionButton(div, "radio", group, option, optionNum, globalNum++, isLast, checked == option);
      }
      // for rendering, the first options' suboptions should be as good as any other
      currentOptions = currentOptions[0].suboptions;
  }

  var touchStartHandler = function (e) {
    if (e.touches.length > 1) return;
    var target = e.target;
    var rect = target.getBoundingClientRect();
    var shuttle;
    var shuttleWidth = rect.width * 0.2;
    //console.log(rect);
    var lastMouse = e.touches[0];
    var draw = function () {
      var transformX = rect.width + rect.left - lastMouse.clientX - (shuttleWidth/2);
      if (transformX < 0) transformX = 0;
      var maxX = rect.width - shuttleWidth - 2;
      if (transformX > maxX) transformX = maxX;
      if (target.parentElement) {
        if (transformX >= maxX * 0.8) {
          target.parentElement.classList.add('selected');
        } else {
          target.parentElement.classList.remove('selected');
        }
      }
      if (shuttle) {
        shuttle.style.transform = "translateX(-" + transformX + "px)"
        shuttle.style.webkitTransform = "translateX(-" + transformX + "px)"
      }
    };
    var outsideTimeout = null;
    var moveTracker = function(e) {
      e.preventDefault();
      lastMouse = e.touches[0];
      // on iPad app, touchend doesn't fire outside webview (touchmove does)
      // so, fire a fake touchend 300 ms after touchmove outside webview
      if (window.isIosApp && window.isIPad) {
        if (outsideTimeout) {
          clearTimeout(outsideTimeout);
          outsideTimeout = null;
        }
        if (lastMouse.pageY < 0 || lastMouse.pageX < 0) {
          outsideTimeout = setTimeout(function() {
            document.body.dispatchEvent(new Event('touchend'));
          }, 300);
        }
      }
      window.requestAnimationFrame(draw);
    }
    if ((e.touches[0].clientX - rect.left) > rect.width - shuttleWidth) {
      shuttle = document.createElement("div");
      shuttle.classList.add("shuttle");
      target.appendChild(shuttle);
      shuttle.style.width = shuttleWidth + "px";
      document.body.addEventListener('touchmove', moveTracker, {passive: false});
      var touchEnd = function(e) {
        document.body.removeEventListener('touchmove', moveTracker, {passive: false});
        document.body.removeEventListener('touchend', touchEnd);
        if (target.parentElement && target.parentElement.classList.contains('selected')) {
          if (target.click) {
            target.click();
          } else {
            var event = document.createEvent('Events');
            event.initEvent("click", true, true);
            target.dispatchEvent(event);
          }
          if (window.isIosApp) {
            window.freezeCallback = function() {
              window.freezeCallback = null;
              form.onsubmit();
            };
            callIos("freeze");
          } else {
            safeCall(null, function() {form.onsubmit();});
          }
        } else {
          if (shuttle.style.opacity !== "0") {
            shuttle.style.opacity = 0;
            var removeShuttle = function(e) {
              if (shuttle.parentElement) shuttle.parentElement.removeChild(shuttle);
            };
            shuttle.addEventListener('transitionend', removeShuttle);
            shuttle.addEventListener('webkitTransitionEnd', removeShuttle);
          } else {
            if (shuttle.parentElement) shuttle.parentElement.removeChild(shuttle);
          }
        }
      };
      document.body.addEventListener('touchend', touchEnd);
    }
    //console.log(e);
  };

  var slidingEnabled = true;
  if (window.slidingEnabled === false || groups.length > 1) slidingEnabled = false;

  if (slidingEnabled) [].forEach.call(document.querySelectorAll('.choice > div'), function(label) {
    label.addEventListener('touchstart', touchStartHandler);
    if (window.isMobile) label.addEventListener('click', function(e) {
      var target = e.currentTarget;
      if (document.body.querySelector(".shuttle.discovery")) return;
      var shuttle = document.createElement("div");
      shuttle.classList.add("shuttle");
      shuttle.classList.add("discovery");
      target.appendChild(shuttle);
      var animationEnd = function(e) {
        if (e.animationName === 'shuttlefadeout') {
          if (shuttle.parentElement) shuttle.parentElement.removeChild(shuttle);
        }
      };
      shuttle.addEventListener('animationend', animationEnd);
      shuttle.addEventListener('webkitAnimationEnd', animationEnd);
    })
  });

  var useRealForm = false;
  if (useRealForm) {
    printButton("Next", form, false);
  } else {
    printButton("Next", main, false, function() {
      form.onsubmit();
    });
  }
}

function printOptionButton(div, type, name, option, localChoiceNumber, globalChoiceNumber, isLast, checked) {
    var line = option.name;
    var unselectable = false;
    if (!name) unselectable = option.unselectable;
    var disabledString = unselectable ? " disabled" : "";
    var id = name + localChoiceNumber + "-" + Math.random().toString(36).substring(2);
    if (!name) name = "choice";
    var radio;
    var div2 = document.createElement("div");
    var label = document.createElement("label");
    // IE doesn't allow you to dynamically specify the name of radio buttons
    if (!/^\w+$/.test(name)) throw new Error("invalid choice group name: " + name);
    label.innerHTML = "<input type='"+type+"' name='"+name+
            "' value='"+localChoiceNumber+"' id='"+id+
            "' "+(checked?"checked":"")+disabledString+">";

    label.setAttribute("for", id);
    if (localChoiceNumber === 0) {
      if (isLast) {
        setClass(label, "onlyChild"+disabledString);
      } else {
        setClass(label, "firstChild"+disabledString);
      }
    } else if (isLast) {
      setClass(label, "lastChild"+disabledString);
    } else if (unselectable) {
      setClass(label, "disabled");
    }
    label.setAttribute("accesskey", globalChoiceNumber);
    if (!unselectable) {
      if (type === "radio" && window.Touch) { // Make labels clickable on iPhone
          label.onclick = function labelClick(evt) {
              try {
                var target = evt.target;
                if (!/label/i.test(target.tagName)) return;
                var button = document.getElementById(target.getAttribute("for"));
                button.checked = true;
              } catch (e) {}
          };
      } else if (/MSIE 6/.test(navigator.userAgent)) {
        label.onclick = function labelClick() {
          try {
            var target = window.event.srcElement;
            if (!/label/i.test(target.tagName)) return;
            var button = document.getElementById(target.getAttribute("for"));
            button.checked = true;
          } catch (e) {}
        };
      }
    }
    printx(line, label);
    
    div2.appendChild(label);
    div.appendChild(div2);
}

function printImage(source, alignment, alt, invert) {
  var img = document.createElement("img");
  if (typeof hashes != 'undefined' && hashes[source]) {
    source += "?hash=" + hashes[source];
  }
  img.src = source;
  if (alt !== null && String(alt).length > 0) img.setAttribute("alt", alt);
  var zoomFactor = getZoomFactor();
  if (zoomFactor !== 1) {
    var size = (zoomFactor * 100) + '%';
    img.style.height = size;
    img.style.width = size;
  }
  if (invert) {
    setClass(img, "invert align"+alignment);
  } else {
    setClass(img, "align"+alignment);
  }
  document.getElementById("text").appendChild(img);
}

function playSound(source) {
  for (var existingAudios = document.getElementsByTagName("audio"); existingAudios.length;) {
    existingAudios[0].parentNode.removeChild(existingAudios[0]);
  }
  if (typeof hashes != 'undefined' && hashes[source]) {
    source += "?hash=" + hashes[source];
  }
  var audio = document.createElement("audio");
  if (audio.play) {
    audio.setAttribute("src", source);
    document.body.appendChild(audio);
    audio.play();
  }
}

function printYoutubeFrame(slug) {
  var wrapper = document.createElement("div");
  setClass(wrapper, "videoWrapper");
  var iframe = document.createElement("iframe");
  iframe.width="560";
  iframe.height="315";
  iframe.src="https://www.youtube.com/embed/"+slug;
  iframe.setAttribute("frameborder", 0);
  iframe.setAttribute("allowfullscreen", true);
  wrapper.appendChild(iframe);
  document.getElementById("text").appendChild(wrapper);
}

function moreGames() {
    if (window.isIosApp) {
      window.location.href = "https://choiceofgames.app.link/jBm199qZXL/";
    } else if (window.isAndroidApp) {
      if (window.isNookAndroidApp) {
        asyncAlert("Please search the Nook App Store for \"Choice of Games\" for more games like this!");
        return;
      }
      if (window.isAmazonAndroidApp) {
        window.location.href = "https://www.amazon.com/gp/mas/dl/android?p=com.choiceofgames.omnibus&t=choofgam-20&ref=moreGames";
      } else {
        window.location.href = "https://play.google.com/store/apps/details?id=com.choiceofgames.omnibus&referrer=utm_medium%3Dweb%26utm_source%3Dmoregames";
      }
    } else if (window.isSteamApp) {
      window.location.href = "https://store.steampowered.com/curator/7026798-Choice-of-Games/";
    } else {
      try {
        if (window.isChromeApp) {
          window.open("https://www.choiceofgames.com/category/our-games/");
        } else if (window.isHeartsChoice) {
          window.location.href = "https://www.heartschoice.com/shop/";
        } else {
          window.location.href = "https://www.choiceofgames.com/category/our-games/";
        }
      } catch (e) {
        // in xulrunner, this will be blocked, but it will trigger opening the external browser
      }
    }
}

function printShareLinks(target, now) {
  if (!target) target = document.getElementById('text');
  var msgDiv = document.createElement("div");
  if (window.isIosApp) {
    if (now) {
      callIos("share");
      return;
    }
    var button = document.createElement("button");
    button.appendChild(document.createTextNode("Share This Game"));
    button.onclick = function() {
      callIos("share");
    };
    msgDiv.appendChild(button);
    target.appendChild(msgDiv);
    return;
  }

  var mobileMesg = "";
  if (window.isAndroidApp) {
    var androidLink = document.getElementById('androidLink');
    var androidUrl;
    if (window.isNookAndroidApp) {
      if (window.nookEan && nookEan != "UNKNOWN") {
        mobileMesg = "  <li><a href='choiceofgamesnook://"+window.nookEan+"'>Rate this app</a> in the Nook App Store</li>\n";
      }
    } else if (androidLink) {
      androidUrl = getAndroidReviewLink();
      if (androidUrl) {
        mobileMesg = "  <li><a href='"+androidUrl+"'>Rate this app</a> in the Google Play Store</li>\n";
      }
    }
  } else if (/webOS/.test(navigator.userAgent) && window.isFile) {
    var palmLink = document.getElementById('palmLink');
    var palmUrl;
    if (palmLink) {
      palmUrl = palmLink.href;
      if (palmUrl) {
        mobileMesg = "  <li><a href='"+palmUrl+"'>Rate this app</a> in the Palm App Catalog</li>\n";
      }
    }
  } else if (window.isChromeApp) {
    var chromeLink = document.getElementById('chromeLink');
    var chromeUrl;
    if (chromeLink) {
      chromeUrl = chromeLink.href;
      if (chromeUrl) {
        mobileMesg = "  <li><a href='"+chromeUrl+"/reviews'>Rate this app</a> in the Chrome Web Store</li>\n";
      }
    }
  }

  var url = window.location.href;
  var links = document.getElementsByTagName("link");
  for (var i = links.length - 1; i >= 0; i--) {
    if (links[i].getAttribute("rel") == "canonical") {
      url = links[i].getAttribute("href") || url;
      break;
    }
  }

  if (/^\//.test(url)) {
    if (window.isWeb) {
      url = window.location.protocol + "//" + window.location.hostname + url;
    } else {
      url = "https://www.choiceofgames.com" + url;
    }
  }

  url = encodeURIComponent(url);
  var title = encodeURIComponent(document.title);
  var dataUriSupported = !/MSIE [67]/.test(navigator.userAgent);

  var twitterHandle = window.isHeartsChoice ? "heartschoice" : "choiceofgames";

  var shareLinkText =
        '<li>'+(dataUriSupported?'<img height="16" width="16" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAsklEQVQ4EWNgoBAwgvQnVq75f+vBG5KMUlMQYZjfHsLIBNJFqmZkPSzEWKsqL8zQXebJICLIDVZuEzUTrg3sAjgPBwNZM7oSolyAzWaYQUS5AKYYG43XBUeWpaPogfGRwwCvAW/efwUbAPMCjI9sKl4DArKXgNXCbIbxkQ2gOAwoNgDsBS5ONgYNJTFkl2FlG2nLwMVv3HsFZlPHBTLifAznrj6Bm47OQI42mBwoM1EFAAAnVCliRFKHdQAAAABJRU5ErkJggg==">':"")+
        ' <a href="http://www.facebook.com/sharer.php?u='+url+'&amp;t='+title+'"'+
        'onclick="if (window.isFile || window.isXul) return true; '+
        'window.open(&quot;http://www.facebook.com/sharer.php?u='+url+'&amp;t='+title+
        '&quot;,&quot;sharer&quot;,&quot;toolbar=0,status=0,width=626,height=436&quot;);return false;" class="spacedLink">Facebook</a></li>'+

        '<li>'+(dataUriSupported?'<img height="16" width="16" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAB/ElEQVQ4EYVTO24UQRB9/dmZkdfCIK0QBhMACQdwgDgMN4AEiYALcAFy7kNABkYkGzhAWMa2DHg9n+7iVfXMaAmAknp7tuvVq1dV3Q6j9SlJlzLEOXgIPPcmRjf5/7ZHdWQRaVPCOjucDYJlcHi8AFLOErz/J4kRQASXGfjYDmhIeDwAKx9xBzz8jxUCgjqSfE8CPWi5EpeTjOu+F36CVcGxrEBBMVDiaDOB5vqVBQu6WAU+dQmnwZuGwkACstxlRDckqWIhMQLPOtfXvfw0AmfA95thqwBNWGg04PktLbTYrEAlXxJTEciemtd+KdvZf7ESTjipEyaazAgyu/2lTTjP2ZpICZZQYSp7gg8kelh5PFj4Kd56ZvhE2IUSMOMVm/niZoPDOqJl0FSAYlYhoNoabSmBiI5pVNouv39w32G3l05wIwaqKRq00XErWGWYFvXz3uAbA4+51lyf+wy9h0JVRqAgfmehc8vmJt7n/CrKP6J81fzqfIPTVOOAo+S9soko+GkzhxgNocUkDfLmosPrsyvwtpTDP5KRWBz2Of4PB3vYr8o9mNuZncfLvRrPdmveJJVNDn0G8yKUMV/pO+p16MVmAn00yvnu9g7erpZ4xAbUrFtXMy42AE9YwmHNxo42lzAd6AtMQ48NgjVVOz+BVNQ9Zql4UI9P/TehBOJIi+EJIAAAAABJRU5ErkJggg==">':"")+
        ' <a href="https://twitter.com/intent/tweet?related='+twitterHandle+'&amp;text='+title+'&amp;url='+url+'&amp;via='+twitterHandle+'" class="spacedLink">Twitter</a></li>';

  var nowMsg = "";
  if (now) nowMsg = "<p>Please support our work by sharing this game with friends!  The more people play, the more resources we'll have to work on the next game.</p>";
  msgDiv.innerHTML = nowMsg + "<ul id='sharelist'>\n"+
    mobileMesg+
    shareLinkText+
    "</ul>\n";
  target.appendChild(msgDiv);
}

function isShareConfigured() {
  return !!document.getElementById("share");
}

function shareAction(e) {
  clearScreen(function() {
    var target = document.getElementById('text');
    printShareLinks(target, "now");
    printButton("Next", target, false, function () {
      clearScreen(loadAndRestoreGame);
    });
    curl();
  });
}

function isReviewSupported() {
  return !!(window.isIosApp || window.isAndroidApp);
}

function prepareReviewPrompt() {
  if (window.isAndroidApp && window.reviewManagerBridge) {
    window.reviewManagerBridge.requestReviewFlow();
  }
}

function promptForReview() {
  var store;
  target = document.getElementById('text');
  function printMessage(store) {
    println("Great! Please post a review of this game on "+store+". It really helps.[n/]", target);
  }
  var anchorText = "Review This Game";
  var href;
  if (window.isSteamApp) {
    printMessage("Steam");
    printLink(target, "#", anchorText, function(e) {
      preventDefault(e);
      try {
        purchase("adfree", function() {});
      } catch (x) {}
      return false;
    });
    return true;
  } else if (window.isIosApp) {
    println("Great! Please post a review of this version of the game on the App Store. It really helps.[n/]", target);
    printLink(target, "#", anchorText, function(e) {
      preventDefault(e);
      try {
        callIos("reviewapp");
      } catch (x) {}
      return false;
    });
    return true;
  } else if (window.isAndroidApp) {
    if (window.reviewManagerBridge) {
      window.reviewManagerBridge.launchReviewFlow();
      return false;
    }
    href = getAndroidReviewLink();
    if (window.isAmazonAndroidApp) {
      printMessage("Amazon's Appstore");
    } else {
      printMessage("the Google Play Store");
    }
    return true;
  } else if (window.isChromeApp) {
    href = document.getElementById('chromeLink').href;
  }
  
  printLink(target, href, anchorText);
  return true;
}

function getAndroidReviewLink() {
  var href = document.getElementById('androidLink').href;
  var package = /id=([\.\w]+)/.exec(href)[1];
  // TODO legacy hosted
  if (window.isOmnibusApp) {
    var omnibus;
    if (/^org\.hostedgames/.test(package)) {
      omnibus = "org.hostedgames.omnibus";
    } else if (/^com\.heartschoice/.test(package)) {
      omnibus = "com.heartschoice.o";
    } else {
      omnibus = "com.choiceofgames.omnibus";
    }
    if (window.isAmazonAndroidApp) {
      return "http://www.amazon.com/gp/mas/dl/android?p="+omnibus+"&t=choofgam-20&ref=rate"
    } else {
      return "https://play.google.com/store/apps/details?id="+omnibus+"&referrer=utm_medium%3Dweb%26utm_source%3D"+window.storeName+"Game";
    }
  } else if (window.isAmazonAndroidApp) {
    return "http://www.amazon.com/gp/mas/dl/android?p="+package+"&t=choofgam-20&ref=rate";
  } else {
    return href;
  }
}

function isFollowEnabled() {
  return false;
  if (!window.isWeb) return false;
  // iOS add to homescreen seems not to like these iframes
  if (window.navigator.standalone) return false;
  if ("localhost" != window.location.hostname && !/\.?choiceofgames\.com$/.test(window.location.hostname)) return false;
  return true;
}

function printFollowButtons() {
  if (!isFollowEnabled()) return;
  // Just FB Like, for now
  var target = document.getElementById('text');
  var iframe = document.createElement('iframe');
  var width = 300;
  var height = 66;
  iframe.setAttribute("src", "//www.facebook.com/plugins/like.php?href=https%3A%2F%2Fwww.facebook.com%2Fchoiceofgames"+
    "&amp;send=false&amp;layout=standard&amp;width="+width+"&amp;show_faces=true&amp;font&amp;colorscheme=light&amp;"+
    "action=like&amp;height="+height+"&amp;appId=190439350983878");
  iframe.setAttribute("scrolling", "no");
  iframe.setAttribute("frameborder", "0");
  iframe.setAttribute("style", "border:none; overflow:hidden; width:"+width+"px; height:"+height+"px;");
  iframe.setAttribute("allowTransparency", "true");
  target.appendChild(iframe);
}

function subscribeLink(e) {
  clearScreen(function() {
    subscribe(document.getElementById('text'), {now:1}, function() {
      clearScreen(loadAndRestoreGame);
    });
    curl();
  });
}

function subscribeByMail(target, options, callback, code) {
  if (options.now) {
    code();
    if (options.allowContinue) safeTimeout(function() {callback("now");}, 0);
  } else {
    println("Click here to subscribe to our mailing list; " + options.message);
    println("");
    printButton("Subscribe", target, false, function() {
        code();
      });
    if (options.allowContinue) {
      printButton("No, Thanks", target, false, function() {
        safeTimeout(function() {callback();}, 0);
      });
    }
    // why is this timeout necessary?
    safeTimeout(function() {printFooter();}, 0);
  }
}

function subscribe(target, options, callback) {
  if (!options.message) options.message = "we'll notify you when our next game is ready!";
  if (typeof options.allowContinue === "undefined") options.allowContinue = 1;
  if (!target) target = document.getElementById('text');
  if (window.isIosApp) {
    subscribeByMail(target, options, callback, function() {
      callIos("subscribe");
    });
    return;
  }
  var mailToSupported = isMobile && !window.isMacApp;
  if (window.isAndroidApp) mailToSupported = urlSupport.isSupported("mailto:support@choiceofgames.com");
  var domain = window.isHeartsChoice ? "heartschoice.com" : "choiceofgames.com";
  if (mailToSupported) {
    subscribeByMail(target, options, callback, function() {
      window.location.href = "mailto:subscribe-"+window.storeName+"-"+platformCode() + "@"+domain+"?subject=Sign me up&body=Please notify me when the next game is ready.";
    });
    return;
  }
  println("Type your email address below; " + options.message);
  println("");
  fetchEmail(function(defaultEmail) {
    promptEmailAddress(target, defaultEmail, options.allowContinue, function(cancel, email) {
      if (cancel) {
        return safeCall(null, callback);
      }
      var head= document.getElementsByTagName('head')[0];
      var script = document.createElement('script');
      script.type = 'text/javascript';
      var timestamp = new Date().getTime();
      var timeout = setTimeout(function() {
        window["jsonp"+timestamp]({
          result:"error", msg:"Couldn't connect. Please try again later."
        });
      }, 10000);
      window["jsonp"+timestamp] = function(response) {
        clearTimeout(timeout);
        if (response.result == "error") {
          var errElement = document.getElementById("errorMessage");
          if (errElement) errElement.innerHTML = response.msg;
        } else {
          clearScreen(function() {
            target = document.getElementById('text');
            println(response.msg, target);
            println("", target);
            if (options.allowContinue) {
              printButton("Next", target, false, function() {
                safeCall(null, callback);
              });
            }
            curl();
          });
        }
      };
      var listId = window.isHeartsChoice ? "fa4134344b" : "e9cdee1aaa";
      var mailParams = "u=eba910fddc9629b2810db6182&id="+listId+"&SIGNUP="+window.storeName+"-"+platformCode()+"&EMAIL="+encodeURIComponent(email);
      if (window.isChromeApp) {
        chrome.permissions.contains({origins: ["http://choiceofgames.us4.list-manage.com/"]},function(isXhrAllowed) {
          if (isXhrAllowed) {
            var xhr = new XMLHttpRequest();
            xhr.open("GET", 'http://choiceofgames.us4.list-manage.com/subscribe/post-json?'+mailParams, true);
            xhr.onreadystatechange = function() {
              if (xhr.readyState != 4) return;
              if (xhr.status == 200) {
                var response = JSON.parse(xhr.responseText);
                window["jsonp"+timestamp](response);
              } else if (xhr.status === 0) {
                window["jsonp"+timestamp]({result:"error", msg:"There was a network error submitting your registration. Please try again later, or email subscribe@choiceofgames.com instead."});
              } else {
                window["jsonp"+timestamp]({result:"error", msg:"Sorry, our mail server had an error. It's our fault. Please try again later, or email subscribe@choiceofgames.com instead."});
              }
            };
            xhr.send();
          } else {
            window.addEventListener('message', function(event) {
              if (window["jsonp"+timestamp]) {
                var jsonpt = window["jsonp"+timestamp];
                window["jsonp"+timestamp] = null;
                if (jsonpt) jsonpt(event.data);
              }
            });
            var iframe = document.createElement("IFRAME");
            iframe.setAttribute("src", "sandbox.html");
            iframe.setAttribute("name", "sandbox");
            iframe.onload = function() {
              iframe.contentWindow.postMessage({email:email, game:window.storeName, platform:platformCode()}, "*");
            };
            document.documentElement.appendChild(iframe);
            return;
          }
        });
      } else {
        if (isWinStoreApp || window.location.protocol == "https:") {
          var xhr = findXhr();
          xhr.open("GET", 'https://www.choiceofgames.com/mailchimp_proxy.php/subscribe/post-json?'+mailParams, true);
          var done = false;
          xhr.onreadystatechange = function() {
            if (done) return;
            if (xhr.readyState != 4) return;
            done = true;
            if (xhr.status == 200) {
              var response = JSON.parse(xhr.responseText);
              window["jsonp"+timestamp](response);
            } else if (xhr.status === 0) {
                window["jsonp"+timestamp]({result:"error", msg:"There was a network error submitting your registration. Please try again later, or email subscribe@choiceofgames.com instead."});
            } else {
              window["jsonp"+timestamp]({result:"error", msg:"Sorry, our mail server had an error. It's our fault. Please try again later, or email subscribe@choiceofgames.com instead."});
            }
          };
          xhr.send();
        } else {
          script.src = 'https://choiceofgames.us4.list-manage.com/subscribe/post-json?'+mailParams+'&c=jsonp' + timestamp;
          head.appendChild(script);
        }
      }
    });
  });
}

function downloadLink(e) {
  if (e && e.preventDefault) e.preventDefault();
  if (isPrerelease()) {
    return asyncAlert("You'll need to wait until after the game is released on " + window.releaseDate);
  }
  clearScreen(function() {
    var text = document.getElementById("text");
    if (window.knownPurchases && window.knownPurchases.adfree) {
      println("You can download the game using the links below.");
      println("");
      var files = {
        Windows: window.downloadName + " Setup " + window.downloadVersion + "-ia32.exe",
        Mac: window.downloadName + "-" + window.downloadVersion + ".dmg",
        Linux: window.downloadPackage + "-" + window.downloadVersion + "-ia32.deb"
      }
      var detectedOs;
      if (/Windows/.test(navigator.userAgent)) {
        detectedOs = "Windows";
      } else if (/Mac OS X/.test(navigator.userAgent)) {
        detectedOs = "Mac";
      } else if (/Linux/.test(navigator.userAgent)) {
        detectedOs = "Linux";
      }
      if (detectedOs) {
        printDownloadLink(detectedOs);
      }
      for (var os in files) {
        if (detectedOs != os) printDownloadLink(os);
      }
      function printDownloadLink(os) {
        var link = document.createElement("a");
        if (detectedOs == os) {
          setClass(link, "next linkButton");
        } else {
          link.style.display = "block";
        }
        link.innerHTML = "Download for " + os;
        link.href = "scenes/"+files[os];
        text.appendChild(link);
      }
      println("");
      printButton("Next", text, false, function() {
        safeCall(null, function() { clearScreen(loadAndRestoreGame); });
      });
    } else {
      println("To download this game for Windows, Mac, or Linux, you'll need to purchase it first.");
      println("");
      printOptions([""], [{name:"Purchase it now.", purchase:1}, {name:"No, thanks.", cancel:1}], function(option) {
        if (option.purchase) {
          purchase("adfree", downloadLink);
        } else {
          safeCall(null, function() { clearScreen(loadAndRestoreGame); });
        }
      });
    }
    curl();
  });
}

function cacheKnownPurchases(knownPurchases) {
  if (!knownPurchases) return;
  var output = {billingSupported:true};
  for (i = 0; i < knownPurchases.length; i++) {
    var parts = knownPurchases[i].split(/\./);
    if (parts[0] != window.storeName) continue;
    output[parts[1]] = true;
  }
  window.knownPurchases = output;
  fetchEmail(function(email) {
    if (email) window.store.set("knownPurchases"+email.replace(/[^A-z0-9]/g, "_"), JSON.stringify(window.knownPurchases));
  })
  if (window.isIosApp) {
    callIos("cachepurchases", knownPurchases);
  } else if (window.isAndroidApp) {
    if (window.isOmnibusApp) {
      androidBilling.cachePurchases(JSON.stringify(knownPurchases));
    }
    androidBilling.updateAdfree(!!output.adfree);
  }
}

// Callback expects a map from product ids to booleans
function checkPurchase(products, callback) {
  function publishPurchaseEvents(purchases) {
    if (purchases && window.purchaseSubscriptions) {
      for (var key in purchaseSubscriptions) {
        if (purchases[key]) purchaseSubscriptions[key].call();
      }
    }
  }

  function checkWebPurchases(callback) {
    isRegistered(function (registered) {
      if (!registered) return callback("ok", {billingSupported: true});
      if (window.knownPurchases) {
        safeTimeout(function() {
          callback("ok", window.knownPurchases);
          publishPurchaseEvents(knownPurchases);
        }, 0);
      } else {
        startLoading();
        xhrAuthRequest("GET", "get-purchases", function(ok, response) {
          doneLoading();
          if (ok) {
            cacheKnownPurchases(response);
            callback(ok, window.knownPurchases);
          } else {
            fetchEmail(function(email) {
              if (!email) return callback(ok, window.knownPurchases);
              window.store.get("knownPurchases"+email.replace(/[^A-z0-9]/g, "_"), function(ok, value) {
                if (ok) {
                  window.knownPurchases = JSON.parse(value);
                }
                callback(ok, window.knownPurchases);
                publishPurchaseEvents(window.knownPurchases);
              })
            });
          }
        });
      }
    });
  }

  function mergeKnownPurchases(purchases) {
    window.checkPurchaseCallback = null;
    checkWebPurchases(function(ok, knownPurchases) {
      if (knownPurchases) {
        var productList = products.split(/ /);
        for (i = 0; i < productList.length; i++) {
          if (knownPurchases[productList[i]]) purchases[productList[i]] = knownPurchases[productList[i]];
        }
      }
      callback("ok", purchases);
      publishPurchaseEvents(purchases);
    });
  }

  var i, oldCallback;
  if (window.isIosApp) {
    oldCallback = window.checkPurchaseCallback;
    window.checkPurchaseCallback = function (purchases) {
      if (oldCallback) oldCallback(purchases);
      mergeKnownPurchases(purchases);
    }
    if (!oldCallback) callIos("checkpurchase", products);
  } else if (window.isAndroidApp && !window.isNookAndroidApp) {
    oldCallback = window.checkPurchaseCallback;
    window.checkPurchaseCallback = function (purchases) {
      if (oldCallback) oldCallback(purchases);
      mergeKnownPurchases(purchases);
    }
    if (!oldCallback) androidBilling.checkPurchase(products);
  } else if (window.isWinOldApp) {
    safeTimeout(function() {
      var purchases = eval(window.external.CheckPurchase(products));
      callback("ok",purchases);
      publishPurchaseEvents(purchases);
    }, 0);
  } else if (window.isMacApp) {
    oldCallback = window.checkPurchaseCallback;
    window.checkPurchaseCallback = function (purchases) {
      if (oldCallback) oldCallback(purchases);
      window.checkPurchaseCallback = null;
      callback("ok",purchases);
      publishPurchaseEvents(purchases);
    }
    if (!oldCallback) callMac("checkpurchase", products);
  } else if (window.isCef) {
    cefQuery({
      request:"CheckPurchases " + products,
      onSuccess: function(response) {
        console.log("cp response " + response);
        var purchases = JSON.parse(response);
        callback("ok",purchases);
        publishPurchaseEvents(purchases);
      },
      onFailure: function(error_code, error_message) {
        console.error("CheckPurchases error: " + error_message);
        callback(!"ok");
      }
    });
  } else if (window.isSteamworks) {
    var steamworksApps = require('../package.json').products;
    var purchases = {};
    var productList = products.split(/ /);
    for (i = 0; i < productList.length; i++) {
      var appId = steamworksApps[productList[i]];
      var purchased = false;
      try {
        purchased = steamworks.apps.isSubscribedApp(appId);
      } catch (e) {
        // we pre-checked adfree on startup
        if (productList[i] === 'adfree') {
          purchased = !window.isTrial;
        } else {
          return safeTimeout(function () { callback(!"ok"); }, 0);
        }
      }
      purchases[productList[i]] = purchased;
    }
    purchases.billingSupported = true;
    publishPurchaseEvents(purchases);
    safeTimeout(function() {callback("ok", purchases);}, 0);
  } else if (window.beta === "beta") {
    var productList = products.split(/ /);
    var purchases = {};
    for (i = 0; i < productList.length; i++) {
      purchases[productList[i]] = true;
    }
    purchases.billingSupported = true;
    publishPurchaseEvents(purchases);
    safeTimeout(function () { callback("ok", purchases); }, 0);
  } else if (isWebPurchaseSupported()) {
    checkWebPurchases(function(ok, knownPurchases) {
      callback(ok, knownPurchases);
      publishPurchaseEvents(window.knownPurchases);
    });
  } else {
    var productList = products.split(/ /);
    var purchases = {};
    for (i = 0; i < productList.length; i++) {
      purchases[productList[i]] = !!window.isChromeApp;
    }
    purchases.billingSupported = false;
    publishPurchaseEvents(purchases);
    safeTimeout(function() {callback("ok", purchases);}, 0);
  }
}

function isWebPurchaseSupported() {
  var enableBilling = (typeof window.enableBilling === 'undefined' || window.enableBilling)
  return enableBilling && isWebSavePossible() && !!window.stripeKey;
}

function isRestorePurchasesSupported() {
  return !!window.isIosApp || !!window.isAndroidApp || isWebPurchaseSupported();
}

function restorePurchases(product, callback) {
  function webRestoreCallback() {
    var purchased = window.knownPurchases && window.knownPurchases[product];
    if (!purchased) {
      if (window.isAndroidApp) {
        return asyncAlert("Restore completed. This product is not yet purchased. "+
          "Sometimes purchases can fail to restore for reasons outside our control. "+
          "If you have already purchased this product, try uninstalling and reinstalling the app. "+
          "If that doesn't work, please email a copy of your receipt to " + getSupportEmail() + " "+
          "and we'll find a way to help you.", function() {
            callback(purchased);
          });
      } else {
        asyncAlert("Restore completed. This product is not yet purchased.");
      }
    } else {
      refreshIfAppUpdateReady();
      updateAllPaidSceneCaches();
    }
    callback(purchased);
  }
  function secondaryRestore(error) {
    window.restoreCallback = null;
    if (product) {
      checkPurchase(product, function(ok, purchases) {
        if (purchases[product]) {
          return callback("purchased");
        }

        if (window.isOmnibusApp && (window.purchaseTransfer || window.omnibusSupportsTransfer)) {
          var appId = getAppId();
          if (window.isAndroidApp || (window.isIosApp && (appId !== "1363309257" && appId !== "1302297731"))) {
            return omnibusRestore(appId);
          }
        }

        clearScreen(function() {
          isRegistered(function (registered) {
            if (registered) {
              startLoading();
              xhrAuthRequest("GET", "get-purchases", function(ok, response) {
                doneLoading();
                if (ok) {
                  cacheKnownPurchases(response);
                  webRestoreCallback();
                } else {
                  if (response.error === "not registered") {
                    logout();
                    secondaryRestore("error");
                  } else {
                    asyncAlert("There was an error restoring purchases. (Your network connection may be down.) Please try again later.");
                    callback();
                  }
                }
              });
            } else {
              var target = document.getElementById('text');
              if (error) {
                target.innerHTML="<p>Restore failed. Please try again later, or sign in to choiceofgames.com to restore purchases.</p>";
              } else {
                target.innerHTML="<p>Restore completed. This product is not yet purchased. You may also sign in to choiceofgames.com to restore purchases.</p>";
              }
              loginForm(document.getElementById('text'), /*optionality*/1, /*err*/null, webRestoreCallback);
              curl();
            }
          })
        });
      });
    } else {
      callback();
    }
  }
  function omnibusRestore(appId) {
    var omnibus = "Choice of Games";
    var canonical = document.querySelector("link[rel=canonical]");
    var canonicalHref = canonical && canonical.getAttribute("href");
    if (/\/user-contributed\//.test(canonicalHref)) {
      omnibus = "Hosted Games";
    }
    var appStore = window.isIosApp ? "App Store"
      : window.isAmazonAndroidApp ? "Amazon Appstore"
      : "Google Play Store";
    var gameTitle = document.getElementById("title").textContent;

    clearScreen(function() {
      printParagraph("Restore completed. "+appStore+" records indicate that "+
        "you have not purchased this product using the \""+omnibus+"\" app, "+
        "but you may have purchased the product in the \""+gameTitle+"\" app, "+
        "or on our website at choiceofgames.com.");

      options = [
        {name:"Restore purchases from choiceofgames.com.", group:"choice", webRestore:true},
        {name:"Restore purchases using the \""+gameTitle+"\" app.", group:"choice", transfer:true},
      ];

      printOptions([""], options, function(option) {
        if (option.webRestore) {
          clearScreen(function() {
            isRegistered(function (registered) {
              if (registered) {
                startLoading();
                xhrAuthRequest("GET", "get-purchases", function(ok, response) {
                  doneLoading();
                  if (ok) {
                    cacheKnownPurchases(response);
                    webRestoreCallback();
                  } else {
                    if (response.error === "not registered") {
                      logout();
                      printParagraph("Sign in to choiceofgames.com to restore purchases.");
                      loginForm(document.getElementById('text'), /*optionality*/1, /*err*/null, webRestoreCallback);
                    } else {
                      asyncAlert("There was an error restoring purchases. (Your network connection may be down.) Please try again later.");
                      callback();
                    }
                  }
                });
              } else {
                printParagraph("Sign in to choiceofgames.com to restore purchases.");
                loginForm(document.getElementById('text'), /*optionality*/1, /*err*/null, webRestoreCallback);
              }
            });
          });
        } else {
          var transferAttempted = false;
          var transferPurchaseCallback = function(result) {
            window.transferPurchaseCallback = null;
            clearScreen(function() {
              var transferPlatform = window.isIosApp ? "ios" : "android";

              if (result === "launch_failed") {
                if (!transferAttempted) {
                  transferAttempted = true;
                  printParagraph(
                    "The \""+gameTitle+"\" app is not installed on your current device. To transfer "+
                    "purchases from another app, you'll need to install the \""+gameTitle+"\" app and "+
                    "this \""+omnibus+"\" app at the same time."
                  );
                } else {
                  printParagraph(
                    "The \""+gameTitle+"\" app is not responding. Try uninstalling and reinstalling "+
                    "the \""+gameTitle+"\" app and trying again.");
                  printParagraph("Sometimes purchases can fail to "+
                    "restore for reasons outside our control. If you have already purchased "+
                    "this product, please email a copy of your receipt to "+
                    "[url=mailto:"+transferPlatform+"-transfer-"+storeName+"-missing@choiceofgames.com]"+transferPlatform+"-transfer-"+storeName+"-missing@choiceofgames.com[/url] and we'll find a way to help "+
                    "you."
                  );
                }

                var appLink = window.isIosApp ? "https://itunes.apple.com/app/id"+appId
                  : window.isAmazonAndroidApp ? "https://www.amazon.com/gp/mas/dl/android?p="+appId
                  : "https://play.google.com/store/apps/details?id="+appId;
                printParagraph("[url="+appLink+"]Download "+gameTitle+" from the "+appStore+"[/url]");

                printOptions([""], [
                  {name:"I've installed the \""+gameTitle+
                    "\" app. Try restoring purchases again.", group:"choice", retry:true},
                  {name:"Cancel.", group:"choice"},
                ], function (option) {
                  if (option.retry) {
                    window.transferPurchaseCallback = transferPurchaseCallback;
                    startLoading();
                    window.isIosApp ? callIos("transferpurchase") : purchaseTransfer.requestTransfer(appId);
                  } else {
                    callback(!"purchased");
                  }
                });
                curl();
              } else if (result === "done") {
                checkPurchase(product, function(ok, purchases) {
                  if (purchases[product]) {
                    callback("purchased");
                  } else {
                    printParagraph("Restore completed. "+appStore+" records indicate that you have not purchased "+
                    "this product in the \""+gameTitle+"\" app.");
                    printParagraph("Sometimes purchases can fail to restore for reasons outside our control. If you "+
                    "have already purchased this product, please email a copy of your receipt "+
                    "to [url=mailto:"+transferPlatform+"-transfer-"+storeName+"-failed@choiceofgames.com]"+transferPlatform+"-transfer-"+storeName+"-failed@choiceofgames.com[/url] and we'll find a "+
                    "way to help you.");
                    var target = document.getElementById('text');
                    printButton("Next", target, false, function() {
                      callback(!"purchased");
                    });
                    curl();
                  }
                });
              } else { // result === "error"
                printParagraph("Restore failed. Please try again later. If this error "+
                  "persists, please contact [url=mailto:"+transferPlatform+"-transfer-"+storeName+"-error@choiceofgames.com]"+transferPlatform+"-transfer-"+storeName+"-error@choiceofgames.com[/url] "+
                  "and we'll find a way to help you.")
                printOptions([""], [
                  {name:"Try again now.", group:"choice", retry:true},
                  {name:"Cancel.", group:"choice"},
                ], function(option) {
                  if (option.retry) {
                    window.transferPurchaseCallback = transferPurchaseCallback;
                    clearScreen(function() {
                      startLoading();
                      window.isIosApp ? callIos("transferpurchase") : purchaseTransfer.requestTransfer(appId);
                    });
                  } else {
                    callback(!"purchased");
                  }
                });
                curl();
              }
            });
          }
          if (window.isIosApp) {
            startLoading();
            window.transferPurchaseCallback = transferPurchaseCallback;
            callIos("transferpurchase");
          } else {
            isRegistered(function(registered) {
              if (registered) {
                window.transferPurchaseCallback = transferPurchaseCallback;
                clearScreen(function() {
                  startLoading();
                  purchaseTransfer.requestTransfer(appId);
                });
              } else {
                clearScreen(function() {
                  printParagraph("To restore purchases in the "+omnibus+" app using the "+gameTitle+" app, you'll first need to sign in using a choiceofgames.com account.");
                  loginForm(document.getElementById('text'), /*optionality*/1, /*err*/null, function(ok) {
                    if (ok) {
                      window.transferPurchaseCallback = transferPurchaseCallback;
                      clearScreen(function() {
                        startLoading();
                        purchaseTransfer.requestTransfer(appId);
                      });
                    } else {
                      webRestoreCallback();
                    }
                  });
                });
              }
            });
          }
        }
      });
      curl();
    });
  }

  if (window.isIosApp) {
    window.restoreCallback = secondaryRestore;
    callIos("restorepurchases");
  } else if (window.isAndroidApp) {
    window.restoreCallback = secondaryRestore;
    androidBilling.forceRestoreTransactions();
  } else if (isWebPurchaseSupported()) {
    isRegistered(function(registered) {
      if (registered) {
        startLoading();
        xhrAuthRequest("GET", "get-purchases", function(ok, response) {
          doneLoading();
          if (ok) {
            cacheKnownPurchases(response);
          } else {
            if (response.error != "not registered") {
              alertify.error("There was an error downloading your purchases from choiceofgames.com. "+
                "Please refresh this page to try again, or contact " + getSupportEmail() + " for assistance.", 15000);
            }
          }
          logout();
          restorePurchases(product, callback);
        });
      } else {
        clearScreen(function() {
          var target = document.getElementById('text');
          target.innerHTML="<p>Please sign in to choiceofgames.com to restore purchases.</p>";
          var steamRestore = false;
          var steamLink = document.getElementById('steamLink');
          if (steamLink && steamLink.href && !/INSERTINSERTINSERT/.test(steamLink.href)) {
            steamRestore = true;
          }
          if (steamRestore) {
            window.steamRestoreCallback = function(response) {
              window.steamRestoreCallback = null;
              if (response) cacheKnownPurchases(response);
              webRestoreCallback();
            }
          }
          loginForm(document.getElementById('text'), /*optional*/1, /*err*/null, webRestoreCallback);
          curl();
        });
      }
    });
  } else {
    safeTimeout(callback, 0);
  }
}
// Callback expects a localized string, or "", or "free", or "guess"
function getPrice(product, callback) {
  if (window.isIosApp) {
    checkForAppUpdates();
    window.priceCallback = callback;
    callIos("price", product);
  } else if (window.isAndroidApp) {
    window.priceCallback = callback;
    androidBilling.getPrice(product);
  } else if (window.isWeb) {
    checkForAppUpdates();
    if (window.productData && window.productData[product] && window.productData[product].amount) {
      safeTimeout(function () {
        callback.call(this, "$"+(productData[product].amount/100));
      }, 0);
    } else {
      safeTimeout(function() {
        if (window.productData && window.productData[product] && window.productData[product].amount) {
          callback.call(this, "$"+(productData[product].amount/100));
        } else {
          callback.call(this, "guess");
        }
      }, 500);
    }
  } else if (window.isSteamworks) {
    if (window.productData && window.productData[product]) {
      safeTimeout(function () {
        callback.call(this, productData[product]);
      }, 0);
    } else {
      window.awaitSteamProductData = function() {
        doneLoading();
        window.awaitSteamProductData = null;
        if (window.productData && window.productData[product]) {
          callback.call(this, productData[product]);
        } else {
          callback.call(this, "hide");
        }
      };
      startLoading();
      safeTimeout(function() {if (window.awaitSteamProductData) awaitSteamProductData();}, 5000);
    }
  } else {
    safeTimeout(function () {
      callback.call(this, "hide");
    }, 0);
  }
}
// Callback expects no args, but should only be called on success
function purchase(product, callback) {
  var purchaseCallback = function() {
    window.purchaseCallback = null;
    refreshIfAppUpdateReady();
    updateAllPaidSceneCaches();
    safeCall(null, callback);
    if (window.purchaseSubscriptions && purchaseSubscriptions[product]) {
      purchaseSubscriptions[product].call();
    }
  };
  if (window.isIosApp) {
    if (!window.purchaseRequiresLogin || window.registered) {
      window.purchaseCallback = purchaseCallback;
      return callIos("purchase", product);
    } else {
      clearScreen(function() {
        var target = document.getElementById('text');
        target.innerHTML="<p>Please sign in to choiceofgames.com to purchase.</p>";
        loginForm(target, /*optional*/1, /*err*/null, function(registered){
          if (registered) {
            if (window.knownPurchases && window.knownPurchases[product]) {
              purchaseCallback();
            } else {
              clearScreen(function() {loadAndRestoreGame("", window.forcedScene);});
              window.doneLoadingCallback = function() {
                window.purchaseCallback = purchaseCallback;
                callIos("purchase", product);
              }
            }
          } else {
            clearScreen(function() {loadAndRestoreGame("", window.forcedScene);});
          }
        });
        curl();
      });
    }
  } else if (window.isAndroidApp) {
    window.purchaseCallback = purchaseCallback;
    var androidStackTrace = androidBilling.purchase(product);
    if (androidStackTrace) throw new Error(androidStackTrace);
  } else if (window.isWinOldApp) {
    window.external.Purchase(product);
  } else if (window.isMacApp) {
    return callMac("purchase", product);
  } else if (window.isSteamworks) {
    var steamworksApps = require('../package.json').products;
    if (window.isSteamDeck) {
      var StoreFlagNone = 0;
      console.log('activating');
      steamworks.overlay.activateToStore(steamworksApps[product], StoreFlagNone);
      console.log('activated');
    } else {
      if (steamworksApps[product]) require("electron").shell.openExternal("steam://advertise/" + steamworksApps[product]);
    }
  } else if (window.isCef) {
    cefQuerySimple("Purchase " + product);
    // no callback; we'll refresh on purchase
  } else if (window.isWeb && !isPrerelease() && product == window.appPurchase) {
    var webStoreFallback = function() {
      window.appPurchase = null;
      purchase(product, callback);
    };
    var clickLink = function(id) {
      var link = document.getElementById(id);
      if (!link) return webStoreFallback();
      var href = link.getAttribute("href");
      if (!href) return webStoreFallback();
      window.location.href = href;
    };
    
    // instead of IAP, send Android users to a store
    if (/Silk/.test(navigator.userAgent)) {
      clickLink("kindleLink");
    } else if (/Android/.test(navigator.userAgent)) {
      clickLink("androidLink");
    } else if (window.steamClobber && document.getElementById("steamLink")) {
      clickLink("steamLink");
    } else {
      webStoreFallback();
    }
  } else if (isWebPurchaseSupported()) {
    if (!window.StripeCheckout) return asyncAlert("Sorry, we weren't able to initiate payment. (Your "+
      "network connection may be down.) Please refresh the page and try again, or contact "+
      "support@choiceofgames.com for assistance.");
    var fullProductName = window.storeName + "." + product;
    function stripe() {
      if (window.productData && window.productData[product]) {
        var data = productData[product];
        return StripeCheckout.open({
          key:         window.stripeKey,
          address:     false,
          amount:      data.amount,
          name:        data.display_name,
          email:       window.recordedEmail,
          panelLabel:  'Buy',
          token:       function(response) {
            clearScreen(function() {
              startLoading();
              xhrAuthRequest("POST", "purchase", function(ok, response) {
                doneLoading();
                if (ok) {
                  cacheKnownPurchases(response);
                  return purchaseCallback();
                } else if (/^card error: /.test(response.error)) {
                  var cardError = response.error.substring("card error: ".length);
                  asyncAlert(cardError);
                  clearScreen(function() {loadAndRestoreGame("", window.forcedScene);});
                } else if ("purchase already in flight" == response.error) {
                  asyncAlert("Sorry, there was an error handling your purchase. Please wait five minutes and try again, or contact support@choiceofgames.com for assistance.");
                  clearScreen(function() {loadAndRestoreGame("", window.forcedScene);});
                } else {
                  asyncAlert("Sorry, there was an error processing your card. (Your "+
                    "network connection may be down.) Please refresh the page and try again, or contact "+
                    "support@choiceofgames.com for assistance.");
                  clearScreen(function() {loadAndRestoreGame("", window.forcedScene);});
                }
                curl();
              }, "stripeToken", response.id, "product", fullProductName, "key", window.stripeKey);
            });
          }
        });
      } else {
        return asyncAlert("Sorry, we weren't able to initiate payment. (Your "+
          "network connection may be down.) Please refresh the page and try again, or contact "+
          "support@choiceofgames.com for assistance.");
      }
    }
    if (window.registered && window.recordedEmail) {
      stripe();
    }
    clearScreen(function() {
      var target = document.getElementById('text');
      target.innerHTML="<p>Please sign in to choiceofgames.com to purchase.</p>";
      loginForm(document.getElementById('text'), /*optional*/1, /*err*/null, function(registered){
        if (registered) {
          if (window.knownPurchases && window.knownPurchases[product]) {
            purchaseCallback();
          } else {
            clearScreen(function() {loadAndRestoreGame("", window.forcedScene);});
            return stripe();
          }
        } else {
          clearScreen(function() {loadAndRestoreGame("", window.forcedScene);});
        }
      });
      curl();
    });
  } else {
    safeTimeout(purchaseCallback, 0);
  }
}

function printDiscount(product, fullYear, oneBasedMonthNumber, dayOfMonth, line, options) {
  if (window.updatedDiscountDates && updatedDiscountDates[product]) {
    var udd = updatedDiscountDates[product];
    fullYear = udd.fullYear;
    oneBasedMonthNumber = udd.oneBasedMonthNumber;
    dayOfMonth = udd.dayOfMonth;
  }
  var shortMonthString = shortMonthStrings[oneBasedMonthNumber];
  var discountTimestamp = new Date(shortMonthString + " " + dayOfMonth + ", " + fullYear).getTime();
  var discountEligible = new Date().getTime() < discountTimestamp;
  var span;
  span = document.createElement("span");
  span.setAttribute("id", "discount_" + product);
  printx(line, span);
  span.innerHTML = span.innerHTML.replace("${choice_discount_ends}", "<span id=discountdate_"+product+">"+shortMonthString + " " + parseInt(dayOfMonth, 10) + "</span>") + " ";

  if (!discountEligible) {
    span.style.display = "none";
  }

  document.getElementById('text').appendChild(span);
}

function rewriteDiscount(product, fullYear, oneBasedMonthNumber, dayOfMonth) {
  if (!window.updatedDiscountDates) window.updatedDiscountDates = {};
  window.updatedDiscountDates[product] = {fullYear:fullYear, oneBasedMonthNumber:oneBasedMonthNumber, dayOfMonth:dayOfMonth};
  var span = document.getElementById("discount_"+product);
  if (!span) return;
  var shortMonthString = shortMonthStrings[oneBasedMonthNumber];
  var discountTimestamp = new Date(shortMonthString + " " + dayOfMonth + ", " + fullYear).getTime();
  var discountEligible = new Date().getTime() < discountTimestamp;
  if (discountEligible) {
    var dateSpan = document.getElementById("discountdate_"+product);
    if (!dateSpan) return;
    span.style.display = "";
    dateSpan.innerHTML = shortMonthString + " " + parseInt(dayOfMonth, 10);
  } else {
    span.style.display = "none";
  }
}

function handleDiscountResponse(ok, response) {
  if (!ok || !response || !window.storeName || !response[storeName]) return;
  if (!window.updatedDiscountDates) window.updatedDiscountDates = {};
  var udds = response[storeName];
  for (var product in udds) {
    if (!udds.hasOwnProperty(product)) continue;
    var udd = udds[product];
    var result = udd.split("-");
    rewriteDiscount(product, result[0], parseInt(result[1],10), parseInt(result[2], 10));
  }
}

function isPrerelease() {
  var steamTrial = window.isSteamApp && window.isTrial;
  if (typeof window != "undefined" && (window.isWeb || steamTrial) && window.releaseDate) {
    if (new Date() > window.releaseDate.getTime()) return false;
    if (/(fullaccess|preview)@choiceofgames.com/.test(getCookieByName("login"))) return false;
    var identity = document.getElementById("identity");
    return !(identity && /(fullaccess|preview)@choiceofgames.com/.test(identity.innerHTML));
  } else {
    return false;
  }
}

function registerNativeAchievement(name) {
  if (window.blockNativeAchievements) return;
  if (window.isIosApp) {
    callIos("achieve", name+"/");
  } else if (window.isMacApp) {
    callMac("achieve", name);
  } else if (window.isWinOldApp) {
    window.external.Achieve(name);
  } else if (window.isCef) {
    cefQuerySimple("Achieve " + name);
  } else if (window.isSteamworks) {
    steamworks.achievement.activate(name);
  }
}

function achieve(name, title, description) {
  if (initStore()) {
    checkAchievements(function() {
      window.store.set("achieved", toJson(nav.achieved));
    })
  }
  registerNativeAchievement(name);
  // Game Center shows a prominent banner; no need to show our own
  if (window.isIosApp && !window.isOmnibusApp) return;
  var escapedTitle = title+"".replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
  var escapedDescription = description+"".replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/\[b\]/g, '<b>')
    .replace(/\[\/b\]/g, '</b>')
    .replace(/\[i\]/g, '<i>')
    .replace(/\[\/i\]/g, '</i>');
  var html = "<b>Achievement: "+escapedTitle+"</b><br>" + escapedDescription;
  alertify.log(html);
}

function checkAchievements(callback) {
  safeTimeout(function() {
    if (!initStore()) return callback();
    window.store.get("achieved", function(ok, value){
      function mergeNativeAchievements(achieved) {
        window.checkAchievementCallback = null;
        var nativeRegistered = {};
        for (var i = 0; i < achieved.length; i++) {
          nav.achieved[achieved[i]] = true;
          nativeRegistered[achieved[i]] = true;
        }
        for (var achievement in nav.achieved) {
          if (nav.achieved[achievement] && !nativeRegistered[achievement]) {
            registerNativeAchievement(achievement);
          }
        }
        callback();
      }
      var alreadyLoadingAchievements = false;
      if (ok && value) {
        var achievementRecord = jsonParse(value);
        for (var achieved in achievementRecord) {
          if (achievementRecord[achieved]) nav.achieved[achieved] = true;
        }
      }
      if (window.isIosApp) {
        alreadyLoadingAchievements = !!window.checkAchievementCallback;
        window.checkAchievementCallback = mergeNativeAchievements;
        if (!alreadyLoadingAchievements) callIos("checkachievements");
      } else if (window.isSteamworks) {
        var nativeAchievements = [];
        for (var i = 0; i < window.achievements; i++) {
          if (steamworks.achievement.isActivated(window.achievements[i].name)) {
            nativeAchievements.push(achievement.name);
          }
        }
        mergeNativeAchievements(nativeAchievements);
      } else if (window.isMacApp) {
        alreadyLoadingAchievements = !!window.checkAchievementCallback;
        window.checkAchievementCallback = mergeNativeAchievements;
        if (!alreadyLoadingAchievements) callMac("checkachievements");
      } else if (window.isWinOldApp) {
        var checkWinAchievements = function () {
          var achieved = eval(window.external.GetAchieved());
          if (achieved) {
            mergeNativeAchievements(achieved);
          } else {
            safeTimeout(checkWinAchievements, 100);
          }
        };
        checkWinAchievements();
      } else if (window.isCef) {
        var checkCefAchievements = function() {
          cefQuery({
            request:"GetAchieved ",
            onSuccess: function(response) {
              //console.log("GetAchieved " + response);
              var achieved = eval(response);
              mergeNativeAchievements(achieved);
            },
            onFailure: function(error_code, error_message) {
              //console.error("GetAchieved error " + error_message);
              if (error_code == 1) {
                safeTimeout(checkCefAchievements, 100);
              } else {
                callback();
              }
            }
          });
        };
        checkCefAchievements();
      } else {
        callback();
      }
    });
  },0);
}

function isAdvertisingSupported() {
  if (typeof window === "undefined") return false;
  return (window.isIosApp || window.isAndroidApp || window.adSupportedDebug);
}

function isFullScreenAdvertisingSupported() {
  return isAdvertisingSupported();
}

function showFullScreenAdvertisement(callback) {
  if (window.isIosApp) {
    callIos("advertisement");
    safeTimeout(callback, 0);
  } else if (window.isAndroidApp && window.adBridge) {
    adBridge.displayFullScreenAdvertisement();
    safeTimeout(callback, 0);
  } else if (window.adSupportedDebug) {
    alert("ad!");
    safeTimeout(callback, 0);
  } else {
    safeTimeout(callback, 0);
  }
}

function showFullScreenAdvertisementButton(buttonName, skipCallback, doneCallback) {
  if (typeof isFullScreenAdvertisingSupported == "undefined" || !isFullScreenAdvertisingSupported()) {
    safeTimeout(skipCallback, 0);
    return;
  }
  startLoading();
  checkPurchase("adfree", function (ok, result) {
    doneLoading();
    if (result.adfree) {
      skipCallback();
    } else {
      printButton(buttonName, main, false, function () {
        showFullScreenAdvertisement(doneCallback);
      })
    }
  });
}

function showTicker(target, endTimeInSeconds, finishedCallback) {
  if (!target) target = document.getElementById('text');
  var div = document.createElement("span");
  div.setAttribute("id", "delayTicker");
  target.appendChild(div);
  var timerDisplay = document.createElement("span");
  div.appendChild(timerDisplay);
  var timer;

  var statsButton = document.getElementById("statsButton");
  if (statsButton) {
    var defaultStatsButtonDisplay = statsButton.style.display;
    statsButton.style.display = "none";
  }

  if (endTimeInSeconds > Math.floor(new Date().getTime() / 1000)) {
    if (window.isAndroidApp) {
      notificationBridge.scheduleNotification(endTimeInSeconds);
    } else if (window.isIosApp) {
      callIos("schedulenotification", endTimeInSeconds);
    }
  }

  function cleanUpTicker() {
    window.blockRestart = false;
    if (window.isAndroidApp) {
      notificationBridge.cancelNotification();
    } else if (window.isIosApp) {
      callIos("cancelnotifications");
    }
    clearInterval(timer);
    if (statsButton) statsButton.style.display = defaultStatsButtonDisplay;
  }

  function formatSecondsRemaining(secondsRemaining, forceMinutes) {
    if (!forceMinutes && secondsRemaining < 60) {
      return ""+secondsRemaining+"s";
    } else {
      var minutesRemaining = Math.floor(secondsRemaining / 60);
      var remainderSeconds;
      if (minutesRemaining < 60) {
        remainderSeconds = secondsRemaining - minutesRemaining * 60;
        return ""+minutesRemaining+"m " + formatSecondsRemaining(remainderSeconds);
      } else if (minutesRemaining < 6000) {
        var hoursRemaining = Math.floor(secondsRemaining / 3600);
        remainderSeconds = secondsRemaining - hoursRemaining * 3600;
        return ""+hoursRemaining+"h " + formatSecondsRemaining(remainderSeconds, true);
      } else {
        var daysRemaining = Math.floor(secondsRemaining / 86400);
        remainderSeconds = secondsRemaining - daysRemaining * 86400;
        return ""+daysRemaining+" days " + formatSecondsRemaining(remainderSeconds, true);
      }
    }
  }

  function tick() {
    var tickerElement = document.getElementById("delayTicker");
    var tickerStillVisible = tickerElement && tickerElement.parentNode && tickerElement.parentNode.parentNode;
    if (!tickerStillVisible) {
      cleanUpTicker();
      return;
    }
    var nowInSeconds = Math.floor(new Date().getTime() / 1000);
    var secondsRemaining = endTimeInSeconds - nowInSeconds;
    if (secondsRemaining >= 0) {
      timerDisplay.innerHTML = "" + formatSecondsRemaining(secondsRemaining) + " seconds remaining";
    } else {
      cleanUpTicker();
      tickerElement.innerHTML = "0s remaining";
      if (finishedCallback) safeCall(null, finishedCallback);
    }
  }

  timer = setInterval(tick, 1000);
  tick();
}

function printButton(name, parent, isSubmit, code) {
  var button;
  if (isSubmit) {
    button = document.createElement("input");
    button.type = "submit";
    button.value = name;
    button.name = name;
  } else {
    button = document.createElement("button");
    button.setAttribute("type", "button");
    printx(name, button);
  }
  setClass(button, "next");
  button.setAttribute("accesskey", "n");
  if (code) button.onclick = function(event) {
    if (window.isIosApp) {
      window.freezeCallback = function() {
        window.freezeCallback = null;
        code(event);
      };
      callIos("freeze");
    } else {
      safeCall(null, function() {code(event);});
    }
  };
  if (!isMobile) try { button.focus(); } catch (e) {}
  parent.appendChild(button);
  return button;
}

function printLink(target, href, anchorText, onclick) {
  if (!target) target = document.getElementById('text');
  var link = document.createElement("a");
  link.setAttribute("href", href);
  link.appendChild(document.createTextNode(anchorText));
  if (onclick) {
    if (link.addEventListener) {
      link.addEventListener("click", onclick, true);
    } else {
      link.onclick = onclick;
    }
  }
  target.appendChild(link);
  target.appendChild(document.createTextNode(" "));
}

function kindleButton(target, query, buttonName) {
  printButton(buttonName, main, false,
    function() {
      try {
        window.location.href="http://www.amazon.com/s?rh=n%3A133140011%2Ck%3A" + encodeURIComponent(query);
      } catch (e) {} // xulrunner will intercept this link and throw an exception, opening it in the external browser
    }
  );
}

function printInput(target, inputOptions, callback, minimum, maximum, step) {
    if (!target) target = document.getElementById('text');
    var form = document.createElement("form");
    target.appendChild(form);
    var self = this;
    form.action="#";


    if (inputOptions.long) {
      var input = document.createElement("textarea");
      input.setAttribute("rows", 4);
    } else {
      var input = document.createElement("input");
      if (inputOptions.numeric) {
        input.setAttribute("type", "number");
        input.setAttribute("min", minimum);
        input.setAttribute("max", maximum);
        step = step || "any";
        input.setAttribute("step", step);
      }
    }

    input.setAttribute("autocomplete", "off");

    input.name="text";
    input.setAttribute("style", "font-size: 25px; width: 90%;");
    form.appendChild(input);

    form.onsubmit = function(e) {
        preventDefault(e);
        if (!input.value && !inputOptions.allow_blank) {
            asyncAlert("Don't just leave it blank!  Type something!");
            return;
        }
        if (window.isIosApp) {
          window.freezeCallback = function() {
            window.freezeCallback = null;
            safeCall(null, function() {callback(input.value);});
          };
          callIos("freeze");
        } else {
          safeCall(null, function() {callback(input.value);});
        }
        return false;
    };

    printButton("Next", form, true);

}

function promptEmailAddress(target, defaultEmail, allowContinue, callback) {
  if (!target) target = document.getElementById('text');
  var form = document.createElement("form");
  var self = this;
  form.action="#";

  var message = document.createElement("div");
  message.style.color = "red";
  message.style.fontWeight = "bold";
  message.setAttribute("id", "errorMessage");
  form.appendChild(message);

  var input = document.createElement("input");
  // This can fail on IE
  try { input.type="email"; } catch (e) {}
  input.name="email";
  input.value=defaultEmail;
  input.setAttribute("style", "font-size: 25px; width: 90%;");
  form.appendChild(input);
  target.appendChild(form);
  println("", form);
  println("", form);
  printButton("Next", form, true);

  if (allowContinue) {
    printButton("No, Thanks", target, false, function() {
      callback(true);
    });
  }

  form.onsubmit = function(e) {
    preventDefault(e);
    safeCall(this, function() {
      var email = trim(input.value);
      if (!/^\S+@\S+\.\S+$/.test(email)) {
        var messageText = document.createTextNode("Sorry, \""+email+"\" is not an email address.  Please type your email address again.");
        message.innerHTML = "";
        message.appendChild(messageText);
      } else {
        recordEmail(email, function() {
          callback(false, email);
        });
      }
    });
  };

  curl();
}

function loginForm(target, optional, errorMessage, callback) {
  if (!isRegisterAllowed() || !initStore()) return safeTimeout(function() {
    callback(!"ok");
  }, 0);
  var optional_start = 1;
  var optional_returning_subscribe = 2;
  var optional_returning_no_subscribe = 3;
  var optional_new_subscribe = 4;
  var optional_new_no_subscribe = 5;
  startLoading();
  fetchEmail(function(defaultEmail) {
    isRegistered(function(registered) {
      if (registered) {
        if (defaultEmail) {
          doneLoading();
          loginDiv(registered, defaultEmail);
          return safeTimeout(callback, 0);
        }
        // Cookie says I'm logged in, but we have no local record of the email address
        return getRemoteEmail(function(ok, response) {
          doneLoading();
          if (ok) {
            if (response.email) {
              loginDiv(registered, response.email);
              return recordEmail(response.email, callback);
            } else {
              // not really logged in after all
              logout();
              loginDiv();
              return loginForm(target, optional, errorMessage, callback);
            }
          } else {
            // missed an opportunity to record email locally. meh.
            return safeCall(null, callback);
          }
        });
      }
      doneLoading();
      var form = document.createElement("form");

    if (!errorMessage) errorMessage = "";

      var escapedEmail = defaultEmail.replace(/'/g, "&apos;");
      var passwordButton;
      if (optional == optional_start) {
        form.innerHTML = "<div id=message style='color:red; font-weight:bold'>"+errorMessage+
          "</div><div class='choice'>"+
          "<div><label for=yes class=firstChild><input type=radio name=choice value=yes id=yes checked> My email address is: "+
          "<input type=email name=email id=email value='"+escapedEmail+"' style='font-size: 25px; width: 11em'></label></div>"+
          ((isWeb && window.facebookAppId) ?"<div><label for=facebook><input type=radio name=choice value=facebook id=facebook > Sign in with Facebook.</label></div>":"")+
          ((isWeb && window.googleAppId) ?"<div><label for=google><input type=radio name=choice value=google id=google > Sign in with Google.</label></div>":"")+
          ((window.steamRestoreCallback) ?"<div><label for=steam><input type=radio name=choice value=steam id=steam > Restore purchases from Steam.</label></div>":"")+
          "<div><label for=no class=lastChild><input type=radio name=choice value=no id=no > No, thanks.</label></div></div>"+
          "<p><label class=noBorder for=subscribe><input type=checkbox name=subscribe id=subscribe checked> "+
          "Email me when new games are available.</label></p>";

        form.email.onclick = function() {
          setTimeout(function() {form.email.focus();}, 0);
        };
        setTimeout(function() {form.email.focus();}, 0);
      } else {
        form.innerHTML = "<div id=message style='color:red; font-weight:bold'>"+errorMessage+
          "</div><span><span>My email address is: </span><input type=email name=email id=email value='"+
          escapedEmail+"' style='font-size: 25px; width: 12em'></span><p><label class=noBorder id=subscribeLabel for=subscribe>"+
          "<input type=checkbox name=subscribe id=subscribe checked> "+
          "Email me when new games are available.</label></p><p>Do you have a choiceofgames.com password?</p>"+
          "<div class='choice'>"+
          "<div><label for=new class=firstChild><input type=radio name=choice value=new id=new checked> No, I'm new.</label></div>"+
          "<div><label for=passwordButton><input type=radio name=choice value=passwordButton id=passwordButton> "+
          "Yes, I have a password: <input id=password type=password name=password disabled class=needsclick style='font-size: 25px; width: 11em'></label></div>"+
          "<div><label for=forgot><input type=radio name=choice value=forgot id=forgot> I forgot my password.</label></div>"+
          ((isWeb && window.facebookAppId)?"<div><label for=facebook><input type=radio name=choice value=facebook id=facebook> Sign in with Facebook.</label></div>":"")+
          ((isWeb && window.googleAppId)?"<div><label for=google><input type=radio name=choice value=google id=google> Sign in with Google.</label></div>":"")+
          (optional ? "<div><label for=no><input type=radio name=choice value=no id=no> Cancel.</label></div>" : "") +
          "</div>";

        var labels = form.getElementsByTagName("label");
        setClass(labels[labels.length-1], "lastChild");

        var password = form.password;
        passwordButton = form.passwordButton;

        passwordButton.parentNode.onclick = function() {
          passwordButton.checked = true;
          passwordButton.onchange();
        };

        var radioButtons = form.choice;
        var onchange = function() {
          var enabled = passwordButton.checked;
          password.disabled = !enabled;
          if (enabled) {
            password.focus();
            setTimeout(function() {password.parentNode.scrollIntoView();}, 0);
          }
        };
        for (var i = radioButtons.length - 1; i >= 0; i--) {
          radioButtons[i].onchange = onchange;
        }
        if (optional) {
          form.subscribe.checked = (optional == optional_returning_subscribe || optional == optional_new_subscribe);
          passwordButton.checked = (optional == optional_returning_subscribe || optional == optional_returning_no_subscribe);
        }
      }

      function showMessage(msg) {
        var message = document.getElementById('message');
        var messageText = document.createTextNode(msg);
        message.innerHTML = "";
        message.appendChild(messageText);
      }

      form.onsubmit = function(event) {
        preventDefault(event);
        var email = trim(form.email.value);
        var subscribe = form.subscribe.checked;
        var choice = getFormValue("choice");
        if ("steam" == choice) {
          window.location.href = "https://www.choiceofgames.com/profile/?steamRestore&gameId=" + window.storeName;
        }
        if ("facebook" == choice) {
          if (!window.FB) return asyncAlert("Sorry, we weren't able to sign you in with Facebook. (Your network connection may be down.) Please try again later, or contact support@choiceofgames.com for assistance.");
          var loginParams = {scope:'email',return_scopes:true};
          if (window.facebookReRequest) loginParams.auth_type = "rerequest";
          return FB.login(function(response){
            if ("connected" == response.status) {
              var grantedScopes = [];
              try { grantedScopes = response.authResponse.grantedScopes.split(","); } catch (e) {}
              var grantedEmail = false;
              for (var i = 0; i < grantedScopes.length; i++) {
                if ("email" == grantedScopes[i]) {
                  grantedEmail = true;
                  break;
                }
              }
              if (grantedEmail) {
                xhrAuthRequest("POST", "facebook-login", function(ok, response){
                  if (ok) {
                    loginDiv(ok, response.email);
                    recordLogin(ok, response.id, response.email);
                    cacheKnownPurchases(response.purchases);
                    safeCall(null, function() {callback("ok");});
                  } else {
                    asyncAlert("Sorry, we weren't able to sign you in with Facebook. (Your network connection may be down.) Please try again later, or contact support@choiceofgames.com for assistance.");
                  }
                }, "app_id", facebookAppId);
              } else {
                showMessage("Sorry, we require an email address to sign you in. Please grant access to your email address, or type your email address below.");
                window.facebookReRequest = true;
              }
            }
          },loginParams);
        }
        if ("google" == choice) {
          if (!window.gapi) return asyncAlert("Sorry, we weren't able to sign you in with Google. (Your network connection may be down.) Please try again later, or contact support@choiceofgames.com for assistance.");
          var done = false;
          return gapi.auth.signIn({callback: function (authResult) {
            if (done) return;
            done = true;
            if (authResult['status']['signed_in']) {
              isRegistered(function(registered) {
                if (!registered) xhrAuthRequest("POST", "google-login", function(ok, response){
                  loginDiv(ok, response.email);
                  recordLogin(ok, response.id, response.email);
                  cacheKnownPurchases(response.purchases);
                  if (ok) {
                    callback("ok");
                  } else {
                    asyncAlert("Sorry, we weren't able to sign you in with Google. Please try again later, or contact support@choiceofgames.com for assistance.");
                  }
                }, "code", authResult['code'], "client_id", googleAppId);
              });
            } else {
              asyncAlert("Sorry, we weren't able to sign you in with Google. Please try again later, or contact support@choiceofgames.com for assistance.");
            }
          }});
        }
        if (!/^\S+@\S+\.\S+$/.test(email) && "no" != choice) {
          showMessage('Sorry, "'+email+'" is not an email address.  Please type your email address again.');
        } else {
          recordEmail(email, function() {
            if ("yes" == choice) {
              clearScreen(function() {
                if (defaultEmail) {
                  optional = subscribe ? optional_returning_subscribe : optional_returning_no_subscribe;
                } else {
                  optional = subscribe ? optional_new_subscribe : optional_new_no_subscribe;
                }
                loginForm(document.getElementById("text"), optional, null, callback);
                curl();
              });
            } else if ("no" == choice) {
              safeCall(null, function() {callback(false);});
            } else if ("new" == choice) {
              target.innerHTML = "";
              window.scrollTo(0,1);
              form = document.createElement("form");
              var escapedEmail = email.replace(/'/g, "&apos;");
              form.innerHTML = "<div id=message style='color:red; font-weight:bold'></div>"+
                "<div>My email address is: </div><div><input type=email name=email id=email value='"+
                escapedEmail+"' style='font-size: 25px; width: 12em'></div>"+
                "<div>Type it again: </div><div><input type=email name=email2 id=email2 autocomplete='off' style='font-size: 25px; width: 12em'></div>"+
                "<div>Enter a new password:&nbsp;</div><div>"+
                "<input type=password name=password id=password style='font-size: 25px; width: 12em'></div>";
              form.onsubmit = function(event) {
                preventDefault(event);
                var email = trim(form.email.value);
                var email2 = trim(form.email2.value);
                if (!/^\S+@\S+\.\S+$/.test(email)) {
                  showMessage('Sorry, "'+email+'" is not an email address.  Please type your email address again.');
                  return;
                } else if (email != email2) {
                  showMessage('Those email addresses don\'t match.  Please type your email address again.');
                  return;
                }
                startLoading();
                form.style.display = "none";
                window.scrollTo(0,1);
                login(email, form.password.value, /*register*/true, subscribe, function(ok, response) {
                  doneLoading();
                  if (ok) {
                    target.innerHTML = "";
                    loginDiv(ok, email);
                    recordLogin(ok, response.id, email);
                    cacheKnownPurchases(response.purchases);
                    // we need another click event so we can launch Stripe in a pop-up
                    if (window.isWeb) {
                      asyncAlert("You have registered successfully.", function() {
                        safeCall(null, function() {callback("ok");});
                      });
                    } else {
                      return safeCall(null, function() {callback("ok");});
                    }
                  } else if ("incorrect password" == response.error) {
                    target.innerHTML = "";
                    loginForm(target, optional, 'Sorry, the email address "'+email+'" is already in use. Please type your password below, or use a different email address.', callback);
                  } else {
                    form.style.display = "";
                    showMessage("Sorry, we weren't able to sign you in. (Your network connection may be down.) Please try again later, or contact support@choiceofgames.com for assistance.");
                  }
                });
              };
              target.appendChild(form);
              println("", form);
              printButton("Next", form, true);
              if (optional) {
                printButton("Cancel", form, false, function() {
                  safeCall(null, function() {callback(false);});
                });
              }
            } else if ("passwordButton" == choice) {
              startLoading();
              form.style.display = "none";
              window.scrollTo(0,1);
              login(email, form.password.value, /*register*/false, form.subscribe.checked, function(ok, response) {
                doneLoading();
                form.style.display = "";
                if (ok) {
                  target.innerHTML = "";
                  loginDiv(ok, email);
                  recordLogin(ok, response.id, email);
                  cacheKnownPurchases(response.purchases);
                  // we need another click event so we can launch Stripe in a pop-up
                  if (window.isWeb) {
                    asyncAlert("You have registered successfully.", function() {
                      safeCall(null, function() {callback("ok");});
                    });
                  } else {
                    return safeCall(null, function() {callback("ok");});
                  }
                } else if ("unknown email" == response.error) {
                  showMessage('Sorry, we can\'t find a record for the email address "'+email+'". Please try a different email address, or create a new account.');
                } else if ("incorrect password" == response.error) {
                  showMessage('Sorry, that password is incorrect. Please try again, or select "I forgot my password" to reset your password.');
                } else {
                  showMessage("Sorry, we weren't able to sign you in. (Your network connection may be down.) Please try again later, or contact support@choiceofgames.com for assistance.");
                }
              });
            } else if ("forgot" == choice) {
              startLoading();
              form.style.display = "none";
              window.scrollTo(0,1);
              forgotPassword(email, function(ok, response) {
                doneLoading();
                form.style.display = "";
                if (ok) {
                  showMessage("We've emailed you a link to reset your password. Please check your email and click on the link, then return here to sign in.");
                  document.getElementById('passwordButton').checked = true;
                  document.getElementById('passwordButton').onchange();
                } else if ("unknown email" == response.error) {
                  showMessage('Sorry, we can\'t find a record for the email address "'+email+'". Please try a different email address, or create a new account.');
                } else {
                  showMessage("Sorry, we weren't able to sign you in. (Your network connection may be down.) Please try again later, or contact support@choiceofgames.com for assistance.");
                }
              });
            }
          });
        }
      };

      target.appendChild(form);
      if (passwordButton && passwordButton.checked) passwordButton.onchange();
      if (optional && optional > 1) document.getElementById("subscribeLabel").style.display = "none";
      printButton("Next", form, true);
      printFooter();
    });
  });
}

function loginDiv(registered, email) {
  var domain = "https://www.choiceofgames.com/";
  var identity = document.getElementById("identity");
  if (!identity) return;
  var emailLink = document.getElementById("email");
  var logoutLink = document.getElementById("logout");
  if (registered) {
    emailLink.setAttribute("href", domain + "profile" + "/");
    emailLink.innerHTML = "";
    emailLink.appendChild(document.createTextNode(email));
    logoutLink.onclick = function(event) {
      preventDefault(event);
      logout();
      loginDiv();
    };
    emailLink.style.display = "block";
    logoutLink.style.display = "block";
  } else {
    emailLink.style.display = "none";
    logoutLink.style.display = "none";
  }
}

function isRegistered(callback) {
  if (window.isWeb) {
    return safeTimeout(function() {
      if (!window.registered) window.registered = !!getCookieByName("login");
      callback(window.registered);
    }, 0);
  } else if (initStore()) {
    return window.store.get("login", function(ok, value) {
      safeTimeout(function() {
        window.registered = ok && value && "false" != value && "0" != value;
        callback(window.registered);
      }, 0);
    });
  } else {
    safeCall(null, function() {
      window.registered = false;
      callback(false);
    });
  }
}

function isRegisterAllowed() {
  return isWebSavePossible();
}

function preventDefault(event) {
  if (!event) event = window.event;
  if (!event) return;
  if (event.preventDefault) {
    event.preventDefault();
  } else {
    event.returnValue = false;
  }
}

function getPassword(target, code) {
  if (!target) target = document.getElementById('text');
  var textArea = document.createElement("textarea");
  textArea.cols = 41;
  textArea.rows = 30;
  setClass(textArea, "savePassword");
  target.appendChild(textArea);
  println("", target);
  printButton("Next", target, false, function() {
    code(false, textArea.value);
  });

  printButton("Cancel", target, false, function() {
    code(true);
  });
}

function showPassword(target, password) {
  if (!target) target = document.getElementById('text');

  var textBuffer = [];
  var colWidth = 40;
  for (var i = 0; i < password.length; i++) {
    textBuffer.push(password.charAt(i));
    if ((i + 1) % colWidth === 0) {
      textBuffer.push('\n');
    }
  }
  password = "----- BEGIN PASSWORD -----\n" + textBuffer.join('') + "\n----- END PASSWORD -----";

  var shouldButton = isMobile;
  if (shouldButton) {
    var button = printButton("Email My Password to Me", target, false,
      function() {
        safeCall(self, function() {
            if (isWeb) {
              // TODO more reliable system
            }
            window.location.href = "mailto:?subject=Save%20this%20password&body=" + encodeURIComponent(password);
        });
      }
    );
    setClass(button, "");
  }

  var shouldTextArea = !isMobile;
  if (shouldTextArea) {
    var textArea = document.createElement("textarea");
    textArea.cols = colWidth + 1;
    textArea.rows = 30;
    setClass(textArea, "savePassword");

    textArea.setAttribute("readonly", true);
    textArea.onclick = function() {textArea.select();};
    textArea.value = (password);
    target.appendChild(textArea);
  }
}

function changeTitle(title) {
  document.title = title;
  var titleTag = document.getElementById("title");
  if (titleTag) {
    titleTag.innerHTML = "";
    titleTag.appendChild(document.createTextNode(title));
  }
  var text = document.getElementById('text');
  if (text) {
    var textTitleTag = document.createElement('h1');
    textTitleTag.classList.add('gameTitle');
    textTitleTag.appendChild(document.createTextNode(title));
    text.appendChild(textTitleTag);
  }
}

function changeAuthor(author) {
  var authorTag = document.getElementById("author");
  if (authorTag) {
    authorTag.innerHTML = "";
    authorTag.appendChild(document.createTextNode("by " + author));
  }
  var text = document.getElementById('text');
  if (text) {
    var textAuthorTag = document.createElement('h2');
    textAuthorTag.classList.add('gameTitle');
    textAuthorTag.appendChild(document.createTextNode("by " + author));
    text.appendChild(textAuthorTag);
  }
}


function reportBug() {
  var prompt = "Please explain the problem. Be sure to describe what you expect, as well as what actually happened.";
  alertify.prompt(prompt, function(ok, body) {
    var statMsg = "(unknown)";
    try {
        var scene = window.stats.scene;
        statMsg = computeCookie(scene.stats, scene.temps, scene.lineNum, scene.indent);
    } catch (ex) {}
    body += "\n\nGame: " + window.storeName;
    if (window.stats && window.stats.scene) {
      body += "\nScene: " + window.stats.scene.name;
      body += "\nLine: " + (window.stats.scene.lineNum+1);
    }
    body += "\nUser Agent: " + navigator.userAgent;
    body += "\nLoad time: " + window.loadTime;
    if (window.Persist) body += "\nPersist: " + window.Persist.type;
    body += "\nversion=" + window.version;
    body += "\n\n" + statMsg;
    if (window.nav && window.nav.bugLog) body += "\n\n" + window.nav.bugLog.join("\n");
    console.log(body);
    if (ok) alertify.prompt("Please type your email address.", function(ok, email) {
      if (ok) xhrAuthRequest("POST", "support-mail", function(ok, response) {
        if (ok) {
          alertify.log("Thank you for reporting a bug!");
        } else {
          alertify.alert("Bug reporting failed. Please email your bug report to support@choiceofgames.com (and be sure to mention that the bug reporter failed!)");
        }
      }, "email", encodeURIComponent(email),
        "subject", encodeURIComponent("bug report " + window.storeName),
        "text", encodeURIComponent(body));
    });
  });
}

window.registered = false;

function getSupportEmail() {
  if (window.storeName) {
    return "support-" + storeName + "-" + platformCode() + "@choiceofgames.com";
  }
  try {
    return document.getElementById("supportEmail").getAttribute("href").substring(7);
  } catch (e) {
    return "support-external@choiceofgames.com";
  }
}

function absolutizeAboutLink() {
  var aboutAnchor = document.getElementById("aboutLink");
  if (aboutLink) {
    var aboutHref = aboutLink.getAttribute("href");
    if (/^https?:/.test(aboutHref)) return;

    var linkTags = document.getElementsByTagName("link");
    var canonical;
    for (var i = 0; i < linkTags.length; i++) {
      if (linkTags[i].getAttribute("rel") == "canonical") {
        canonical = linkTags[i].getAttribute("href");
        break;
      }
    }

    if (!canonical) return;

    var absoluteCanonical;
    if (/^https?:/.test(canonical)) {
      absoluteCanonical = canonical;
    } else if (/^\//.test(canonical)) {
      absoluteCanonical = "https://www.choiceofgames.com" + canonical;
    } else {
      absoluteCanonical = "https://www.choiceofgames.com/" + canonical;
    }
    if (!/\/$/.test(canonical)) {
      absoluteCanonical += "/";
    }

    aboutLink.setAttribute("href", absoluteCanonical + aboutHref);
  }
}

function aboutClick() {
    window.location.href = document.getElementById("aboutLink").href;
}

function loadPreferences() {
  if (initStore()) {
    store.get("preferredZoom", function(ok, preferredZoom) {
      if (ok && !isNaN(parseFloat(preferredZoom))) {
        setZoomFactor(parseFloat(preferredZoom));
      }
    });
    store.get("preferredBackground", function(ok, preferredBackground) {
      if (!/^(sepia|black|white)$/.test(preferredBackground)) {
        preferredBackground = "sepia";
      }
      if (preferredBackground === "black") {
        document.body.classList.add("nightmode");
      } else if (preferredBackground === "white") {
        document.body.classList.add("whitemode");
      }
    });
    store.get("preferredAnimation", function(ok, preferredAnimation) {
      window.animateEnabled = parseFloat(preferredAnimation) !== 2;
    });
    store.get("preferredSliding", function (ok, preferredSliding) {
      window.slidingEnabled = preferredSliding !== false && preferredSliding !== "false";
    });
  } else {
    window.animateEnabled = true;
  }
  if (typeof document.body.style.animationName === "undefined") {
    if (typeof document.body.style.webkitAnimationName === "undefined") {
      window.animateEnabled = false;
    } else {
      window.animationProperty = "webkitAnimationName";
    }
  } else {
    window.animationProperty = "animationName";
  }
  if (window.isCef || document.getElementsByTagName("audio").length) {
    delete window.animationProperty;
  }
}

window.addEventListener("error", function (event) {
    var msg = event.message;
    var file = event.source;
    var line = event.lineno;
    var column = event.colno;
    var error = event.error;
    window.reportError(msg, file, line, column, error);
});

window.reportError = function(msg, file, line, column, error) {
    if (window.console) {
      window.console.error(msg);
      if (file) window.console.error("file: " + file);
      if (line) window.console.error("line: " + line);
      if (column) window.console.error("column: " + column);
      if (error) window.console.error(error);
    }
    if (window.isWeb && error && error.name === "QuotaExceededError" && window.location.host === 'www.choiceofgames.com') {
      window.location.assign('https://www.choiceofgames.com/clear-site-data/?qee=1&path=' + encodeURIComponent(window.location.pathname));
    }
    alert(msg);
    if (!isWebSavePossible()) return;
    var ok = confirm("Sorry, an error occurred.  Click OK to email error data to support.");
    if (ok) {
        var statMsg = "(unknown)";
        try {
          var scene = window.stats.scene;
          statMsg = computeCookie(scene.stats, scene.temps, scene.lineNum, scene.indent);
        } catch (ex) {}
        var body = "What were you doing when the error occurred?\n\nError: " + msg;
        body += "\n\nGame: " + window.storeName;
        if (window.stats && window.stats.scene) {
          body += "\nScene: " + window.stats.scene.name;
          body += "\nLine: " + (window.stats.scene.lineNum + 1);
        }
        if (file) body += "\nJS File: " + file;
        if (line) body += "\nJS Line: " + line;
        if (column) body += "\nJS Column: " + column;
        if (error) body += "\nJS Stack: " + error;
        body += "\nUser Agent: " + navigator.userAgent;
        body += "\nLoad time: " + window.loadTime;
        if (window.Persist) body += "\nPersist: " + window.Persist.type;
        body += "\n\n" + statMsg + "\n\nversion=" + window.version;
        if (window.currentVersion) {
          body += "\ncurrentVersion=" + window.currentVersion;
        }
        if (window.nav && window.nav.bugLog) body += "\n\n" + window.nav.bugLog.join("\n");
        var supportEmailHref = "mailto:support-external@choiceofgames.com";
        try {
          supportEmailHref="mailto:"+getSupportEmail();
          supportEmailHref=supportEmailHref.replace(/\+/g,"%2B");
        } catch (e) {}
        window.location.href=(supportEmailHref + "?subject=Error Report&body=" + encodeURIComponent(body));
    }
}

window.onload=function() {
    if (window.alreadyLoaded) return;
    window.alreadyLoaded = true;
    setTimeout(updateAllPaidSceneCaches, 0);
    window.main = document.getElementById("main");
    var head = document.getElementsByTagName("head")[0];
    window.nav.setStartingStatsClone(window.stats);
    loadPreferences();
    if (window.achievements && window.achievements.length) {
      nav.loadAchievements(window.achievements);
      checkAchievements(function() {});
      setButtonTitles();
    }
    nav.loadProducts(window.knownProducts, window.purchases);
    stats.sceneName = window.nav.getStartupScene();
    var map = parseQueryString(window.location.search);
    if (!map) {
      var hashMap = parseQueryString(window.location.hash);
      var realHash = false;
      for (var key in hashMap) {
        if (/^utm_/.test(key)) continue;
        realHash = true;
        break;
      }
      if (realHash) map = hashMap;
    }

    if (!map) {
      if (window.androidQueryString) {
        map = parseQueryString(window.androidQueryString.get());
      } else if (window.forcedScreenshots) {
        map = {forcedScene:"screenshots"};
      }
    }

    if (map) {
      window.forcedScene = map.forcedScene;
      window.slot = map.slot;
      window.debug = map.debug;
      if (map.restart) {
        restartGame();
      } else if (map.achievements) {
        doneLoading();
        showAchievements("hideNextButton");
      } else if (map.omnibusRestore) {
        restorePurchases('adfree', function(purchased) {
          if (window.isIosApp) {
            callIos('close');
          } else {
            setTimeout(function() {window.closer.close()}, 0);
          }
        });
      } else if (map.forcedScene) {
        safeCall(null, function() {loadAndRestoreGame(window.slot, window.forcedScene);});
      } else if (map.persistence) {
        var persistenceParts = map.persistence.split("|");
        if (persistenceParts.length == 2) {
          window.storeName = persistenceParts[0];
          window.remoteStoreName = persistenceParts[1];
        } else {
          window.storeName = map.persistence;
        }
        var startupScene = new Scene("startup", window.stats, window.nav, {secondaryMode:"startup", saveSlot:"startup"});
        startupScene.startupCallback = function() {
          safeCall(null, loadAndRestoreGame);
        }
        startupScene.execute();
      } else if (map.textOptionsMenu) {
        if (initStore()) {
          store.get("preferredSliding", function (ok, preferredSliding) {
            textOptionsMenu();
          });
        } else {
          textOptionsMenu();
        }
      } else {
        safeCall(null, loadAndRestoreGame);
      }
    } else {
      safeCall(null, loadAndRestoreGame);
    }
    if (window.beta) {
      var reportBugButton = document.getElementById("bugButton");
      if (reportBugButton) reportBugButton.setAttribute("style", "");
    }
    if (window.Touch && window.isWeb) {
      // INSERT ADMOB AD
    }
    isRegistered(function(registered){
      if (registered) {
        fetchEmail(function(email) {
          loginDiv(registered, email);
        });
      } else {
        loginDiv();
      }
    });
    if (window.isWinStoreApp || window.isWinOldApp) {
        var subscribeAnchor = document.getElementById("subscribeLink");
        if (subscribeAnchor) {
            subscribeAnchor.onclick = function() {
              safeCall(null, subscribeLink);
              return false;
            };
        }
    }
    if (window.isWinOldApp) {
        absolutizeAboutLink();
        var h1s = document.getElementsByTagName("h1");
        if (h1s.length) window.external.SetTitle(document.getElementsByTagName("h1")[0].innerText);
    }
    if (isFollowEnabled()) {
      var shareElement = document.getElementById("share");
      if (shareElement) shareElement.innerHTML = '<iframe src="//www.facebook.com/plugins/like.php?href=https%3A%2F%2Fwww.facebook.com%2Fchoiceofgames&amp;send=false'+
      '&amp;layout=button_count&amp;width=90&amp;show_faces=true&amp;font&amp;colorscheme=light&amp;action=like&amp;height=20&amp;appId=190439350983878"'+
      ' scrolling="no" frameborder="0" style="border:none; overflow:hidden; width:90px; height:20px;" allowTransparency="true"></iframe>'+
      '<iframe allowtransparency="true" frameborder="0" scrolling="no" '+
      'src="//platform.twitter.com/widgets/follow_button.html?screen_name=choiceofgames&amp;show_screen_name=false"'+
      ' style="width:160px; height:20px;"></iframe>';
    }
    var supportEmailLink = document.getElementById("supportEmail");
    if (window.storeName && supportEmailLink) {
      supportEmailLink.href = "mailto:" + getSupportEmail();
    }

    submitAnyDirtySaves();

    if (window.purchaseSubscriptions) {
      var productList = "";
      for (var key in purchaseSubscriptions) {
        productList += (productList ? " " : "") + key;
      }
      if (productList) checkPurchase(productList, function() {});
    }
    if (window.isWeb) {
      (function() {
        if (isPrerelease()) {
          var appLinks = document.getElementById('mobileLinks');
          if (appLinks) appLinks.style.display = 'none';
        }
        var productMap = {};
        if (typeof purchases === "object") {
          for (var scene in purchases) {
            productMap[purchases[scene]] = 1;
          }
        }
        if (!window.knownProducts) window.knownProducts = [];
        for (var product in productMap) {
          window.knownProducts.push(product);
        }

        var fullProducts = [];
        for (var i = 0; i < window.knownProducts.length; i++) {
          fullProducts[i] = window.storeName + "." + window.knownProducts[i];
        }
        xhrAuthRequest("GET", "product-data", function(ok, data) {
          if (!window.productData) window.productData = {};
          for (var i = 0; i < window.knownProducts.length; i++) {
            window.productData[window.knownProducts[i]] = data[window.storeName + "." + window.knownProducts[i]];
          }
        }, "products", fullProducts.join(","));
      })();
    }

    document.addEventListener('keydown', function(ev) {
      var inputType;
      if (ev.target && ev.target.tagName === 'INPUT') {
        inputType = ev.target.type;
      }
      if (inputType === 'text' || inputType === 'number' || inputType === 'email' || inputType === 'password') return;
      var dialog = document.getElementById('keyboardShortcuts');
      if (dialog && dialog.open) {
        dialog.close();
        return;
      }
      var key = ev.key;

      function clickRadio(value) {
        var newTarget = document.querySelector('input[type=radio][value="' + value + '"]');
        if (!newTarget) return;
        if (newTarget.disabled) return;
        newTarget.checked = true;
        newTarget.focus();
        newTarget.blur();
      }

      function clickButton(id) {
        var button = document.querySelector('#' + id);
        if (!button) return;
        button.click();
      }

      if (key === 'j') {
        var oldTarget = document.querySelector('input[type=radio]:checked');
        if (!oldTarget) {
          var input = document.querySelector('#main input');
          if (input) {
            setTimeout(function () { input.focus(); }, 0);
          }
          return;
        }
        var value = Number(oldTarget.value);
        var availableValues = [...oldTarget.form.querySelectorAll('input[type=radio]:not(:disabled)')].map(e => Number(e.value));
        var valueIndex = availableValues.indexOf(value);
        if (value === availableValues.at(-1)) {
          value = availableValues[0];
        } else {
          value = availableValues[valueIndex + 1];
        }
        clickRadio(value);
      } else if (key === 'k') {
        var oldTarget = document.querySelector('input[type=radio]:checked');
        if (!oldTarget) return;
        var value = Number(oldTarget.value);
        var availableValues = [...oldTarget.form.querySelectorAll('input[type=radio]:not(:disabled)')].map(e => Number(e.value));
        var valueIndex = availableValues.indexOf(value);
        if (value == availableValues[0]) {
          value = availableValues.at(-1);
        } else {
          value = availableValues[valueIndex - 1];
        }
        clickRadio(value);
      } else if (!isNaN(key)) {
        if (key === "0") {
          key = 10;
        }
        key--;
        clickRadio(key);
      } else if (key === 'q') {
        clickButton('statsButton');
      } else if (key === 'w') {
        clickButton('menuButton');
      } else if (key === 'Enter') {
        if (ev.target && (ev.target.tagName === "BUTTON" || ev.target.tagName === "A")) {
          if (ev.target.getAttribute("accesskey") === "n") {
            ev.preventDefault();
          } else {
            return;
          }
        }
        if (window.activatingNext) return;
        var checked = document.querySelector('input[type=radio]:checked');
        if (checked) {
          var label = document.querySelector('label[for="' + checked.id + '"]').parentElement;
          if (label) {
            label.scrollIntoView({ block: "nearest", behavior: 'smooth' });
            label.classList.add('selectedKeyboard');
          }
          window.activatingNext = Date.now();
        } else {
          var n;
          if (ev.target && (ev.target.tagName === "BUTTON" || ev.target.tagName === "A")) {
            n = ev.target;
          } else {
            n = document.querySelector('*[accesskey="n"]');
          }
          if (n) {
            n.scrollIntoView({block: "nearest", behavior: 'smooth'});
            n.classList.add('selectedKeyboard');
            window.activatingNext = Date.now();
          }
        }
      } else if (key === '?' || key === '/') {
        if (dialog && dialog.showModal) dialog.showModal();
      }
    }, false);

    document.addEventListener('keyup', function (ev) {
      var now = Date.now();
      var activatingNext = window.activatingNext;
      window.activatingNext = null;
      if (ev.key === 'Enter' && activatingNext && (now - activatingNext > 500)) {
        var n = document.querySelector('.selectedKeyboard');
        if (n && (n.tagName === 'BUTTON' || n.tagName === 'A')) {
          n.click();
        } else {
          n = document.querySelector('*[accesskey="n"]');
          if (n) n.click();
        }
      }
      document.querySelectorAll('.selectedKeyboard').forEach(function (selected) {
        selected.classList.remove('selectedKeyboard');
      })
    });

};

if ( document.addEventListener ) {
  document.addEventListener( "DOMContentLoaded", window.onload, false );
}

try {
  var style = document.createElement('style');
  style.type = 'text/css';
  try {style.innerHTML = 'noscript {display: none;}'; } catch (e) {}
  document.getElementsByTagName('head')[0].appendChild(style);
} catch (e) {}

if (window.isWeb) {
  document.getElementById("dynamic").innerHTML = ".webOnly { display: block; }";
  var checkoutScript = document.createElement("script");
  checkoutScript.async = 1;
  checkoutScript.src="https://checkout.stripe.com/v2/checkout.js";
  document.getElementsByTagName("head")[0].appendChild(checkoutScript);

  var metas = document.getElementsByTagName("meta");
  var facebookAppId, googleAppId;
  var googleLoginCallbackCallback;
  for (var i = 0; i < metas.length; i++) {
    var meta = metas[i];
    if ("fb:app_id" == meta.getAttribute("property")) {
      facebookAppId = meta.getAttribute("content");
    } else if ("google-signin-clientid" == meta.getAttribute("name")) {
      googleAppId = meta.getAttribute("content");
    }
  }

  if (facebookAppId) {
    window.fbAsyncInit = function() {
      FB.init({
        appId      : facebookAppId,
        cookie     : true,  // enable cookies to allow the server to access 
                            // the session
        xfbml      : true,  // parse social plugins on this page
        version    : 'v2.0' // use version 2.0
      });
    };

    (function(d, s, id){
        var js, fjs = d.getElementsByTagName(s)[0];
        if (d.getElementById(id)) return;
        js = d.createElement(s); js.id = id;
        js.src ="//connect.facebook.net/en_US/sdk.js";
        fjs.parentNode.insertBefore(js, fjs);
    }(document,'script','facebook-jssdk'));
  }

  if (googleAppId) (function() {
    var po = document.createElement('script'); po.type = 'text/javascript'; po.async = true;
    po.src = 'https://apis.google.com/js/client:plusone.js';
    var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(po, s);
  })();
  
} else if (window.isIosApp) {
  document.getElementById("dynamic").innerHTML =
  "#text .gameTitle { display: block; }"+
  ""+
  "#header { display: none; }"+
  ""+
  "body { transition-duration: 0; }"+
  ""+
  "#emailUs { display: none; }"+
  ""+
  "#main { padding-top: 1em; }";
  // Use UIWebView width, not screen width, on iPad
  document.querySelector("meta[name=viewport]").setAttribute("content", "width="+window.innerWidth);
  window.addEventListener("resize", function() {
      document.querySelector("meta[name=viewport]").setAttribute("content", "width="+window.innerWidth);
      // this dummy element seems to be required to get the viewport to stick
      var dummy = document.createElement("p");
      dummy.innerHTML = "&nbsp;";
      document.body.appendChild(dummy);
      window.setTimeout(function() {document.body.removeChild(dummy);}, 10);
    }, false);
  callIos("checkdiscounts");
  // in a timeout because iOS may try to add to the head before mygame.js has run
  (function(){
    var requester = function() {
      if (window.stats) {
        callIos("requestscenes");
      } else {
        safeTimeout(requester, 1);
      }
    }
    if (window.isFile) safeTimeout(requester, 0);
  })();
} else if (window.isAndroidApp) {
  document.getElementById("dynamic").innerHTML =
  "#text .gameTitle { display: block; }" +
  "" +
  "#header { display: none; }"+
  ""+
  "#emailUs { display: none; }"+
  ""+
  "#main { padding-top: 1em; }";
} else if (window.isMacApp || window.isWinOldApp || window.isCef || window.isNode || window.isSteamApp) {
  document.getElementById("dynamic").innerHTML =
    "#header .gameTitle { display: none; }" +
    "" +
    "#text .gameTitle { display: block; }" +
    "" +
    "#headerLinks { display: none; }"+
    ""+
    "#emailUs { display: none; }";
}
// on touch devices, this hover state never goes away
if (!('ontouchstart' in window)) {
  document.getElementById("dynamic").innerHTML += ".choice > div:hover {background-color: #E4DED8;}\n" +
    "body.nightmode .choice > div:hover {background-color: #555;}\n"+
    "body.whitemode .choice > div:hover {background-color: #ddd;}\n";
}
function fixChromeLinks() {
  var aboutLink = document.getElementById("aboutLink");
  aboutLink.addEventListener("click", function() {
    if (chrome.app.window) {
      event.preventDefault();
      chrome.app.window.create("credits.html", {}, function(w) {
        w.contentWindow.addEventListener( "DOMContentLoaded", function() {
          var win = this;
          var back = win.document.getElementById("back");
          back.addEventListener("click", function(event) {
            event.preventDefault();
            win.close();
          }, false);
          var base = win.document.createElement('base');
          base.setAttribute("target", "_blank");
          win.document.head.appendChild(base);
          win.document.documentElement.style.overflowY = "scroll";
        }, false);
      });
    }
  }, false);

  var statsButton = document.getElementById("statsButton");
  if (statsButton) {
    statsButton.onclick = undefined;
    statsButton.addEventListener("click", function() {
      showStats();
    }, false);
  }

  var achievementsButton = document.getElementById("achievementsButton");
  if (achievementsButton) {
    achievementsButton.onclick = undefined;
    achievementsButton.addEventListener("click", function() {
      showAchievements();
    }, false);
  }

  var restartButton = document.getElementById("restartButton");
  restartButton.onclick = undefined;
  restartButton.addEventListener("click", function() {
    restartGame("prompt");
  }, false);

  var subscribeAnchor = document.getElementById("subscribeLink");
  subscribeAnchor.onclick = undefined;
  subscribeAnchor.addEventListener("click", function() {
    subscribeLink();
  }, false);

  var supportAnchor = document.getElementById("supportEmail");
  supportAnchor.addEventListener("click", function(event) {
    event.preventDefault();
  }, false);

  var menuButton = document.getElementById("menuButton");
  menuButton.onclick = undefined;
  menuButton.addEventListener("click", function() {
    textOptionsMenu();
  }, false);
}
if (window.isChromeApp) {
  var base = document.createElement('base');
  base.setAttribute("target", "_blank");
  document.head.appendChild(base);

  document.addEventListener("DOMContentLoaded", fixChromeLinks);
  setInterval(function() {
    document.body.style.height = document.querySelector(".container").offsetHeight + "px";
  }, 100);
}
if (window.isCef) {
  var pollPurchases = function() {
    cefQuery({
      request:"PollPurchases",
      onSuccess:function(response){
        //console.log("PollPurchases: '"+response+"'");
        if (response) {
          clearScreen(loadAndRestoreGame);
        }
        safeTimeout(pollPurchases, 100);
      },
      onFailure:function(error_code, error_message) {
        console.error("PollPurchases error: " + error_message);
      }
    });
  };
  pollPurchases();
} else if (window.isSteamworks) {
	(function() {
		var steamworksApps = require('../package.json').products;
		if (typeof steamworksApps === "undefined") throw new Error("package.json missing products");
		var steamworksAppId = window.isTrial ? steamworksApps.steam_demo : steamworksApps.adfree;
    if (steamworksApi.restartAppIfNecessary(steamworksAppId)) {
      require('electron').ipcRenderer.invoke('quit');
      return;
    }
    try {
      window.steamworks = steamworksApi.init(steamworksAppId);
    } catch (e) {
      alert("There was an error connecting to Steam. Steam must be running" +
        " to play this game. If you launched this game using Steam, try restarting Steam" +
        " or rebooting your computer. If that doesn't work, try completely uninstalling" +
        " Steam and downloading a fresh copy from steampowered.com.\n\nIf none of that works, please contact" +
        " support@choiceofgames.com and we'll try to help. (Mention error code 77779.)")
      require('electron').ipcRenderer.invoke('quit');
      return;
    }
    var adfreePurchased;
    try {
      adfreePurchased = steamworks.apps.isSubscribedApp(steamworksApps.adfree);
    } catch (e) {
      alert("There was an error connecting to Steam. Steam must be running" +
        " to play this game. If you launched this game using Steam, try restarting Steam" +
        " or rebooting your computer. If that doesn't work, try completely uninstalling" +
        " Steam and downloading a fresh copy from steampowered.com.\n\nIf none of that works, please contact" +
        " support@choiceofgames.com and we'll try to help. (Mention error code 77776.)")
      require('electron').ipcRenderer.invoke('quit');
      return;
    }
		if (window.isTrial && adfreePurchased) {
			alert("This is the demo version of the game, " +
				"but you now own the full version. The demo will now exit. Your progress has been saved." +
				" Please launch the full version of the game using Steam.");
			require('electron').ipcRenderer.invoke('quit');
    } else if (!window.isTrial && !adfreePurchased) {
      alert("There was an error connecting to Steam. Steam must be running" +
        " to play this game. If you launched this game using Steam, try restarting Steam" +
        " or rebooting your computer. If that doesn't work, try completely uninstalling" +
        " Steam and downloading a fresh copy from steampowered.com.\n\nIf none of that works, please contact" +
        " support@choiceofgames.com and we'll try to help. (Mention error code 77778.)")
      require('electron').ipcRenderer.invoke('quit');
      return;
    }
    window.isSteamDeck = !!(steamworks.utils.isSteamRunningOnSteamDeck && steamworks.utils.isSteamRunningOnSteamDeck());
		if (window.isSteamDeck) {
      window.addEventListener("DOMContentLoaded", function() {
        window.document.body.addEventListener('click', function (event) {
          if (event.target && event.target.tagName === 'A' && event.target.href !== '#') {
            event.preventDefault();
            var href = event.target.href;
            var intercepts = require('../package.json').intercepts;
            for (var intercept in intercepts) {
              if (href.startsWith(intercept)) {
                var StoreFlagNone = 0;
                console.log('activating to store');
                steamworks.overlay.activateToStore(intercepts[intercept], StoreFlagNone);
                console.log('activated');
                return;
              }
            }
            console.log('activating to web');
            steamworks.overlay.activateToWebPage(href);
            console.log('activated');
          }
        });
      })
    }
    var pollPurchases = function(oldCount) {
			var count = 0;
			for (var product in steamworksApps) {
				if (steamworks.apps.isSubscribedApp(steamworksApps[product])) {
					count++;
				}
			}
			if (count != oldCount && typeof oldCount !== "undefined") clearScreen(loadAndRestoreGame);
			safeTimeout(function() {pollPurchases(count)}, 100);
		};
		pollPurchases();

    var appIds = [];
    for (var product in steamworksApps) {
      appIds.push(steamworksApps[product]);
    }

    xhrAuthRequest("GET", "steam-price", function(ok, data) {
      if (!window.productData) window.productData = {};
      for (var product in steamworksApps) {
        window.productData[product] = data[steamworksApps[product]];
      }
      if (window.awaitSteamProductData) window.awaitSteamProductData();
    }, "user_id", steamworks.localplayer.getSteamId().steamId64, "app_ids", appIds.join(","));
	})();
}

function winStoreShareLinkHandler(e) {
    var request = e.request;
    var canonical = document.querySelector("link[rel=canonical]");
    var canonicalHref = canonical && canonical.getAttribute("href");
    if (!/^https?:/.test(canonicalHref)) {
        canonicalHref = "https://www.choiceofgames.com" + canonicalHref;
    }
    if (!/\/$/.test(canonicalHref)) {
        canonicalHref += "/";
    }
    canonicalHref += "redirect.php?src=winshare";
    request.data.properties.title = document.title;
    request.data.properties.description = document.querySelector("meta[name=description]").getAttribute("content");
    request.data.setUri(new Windows.Foundation.Uri(canonicalHref));
}

if (window.isWinStoreApp) {
    var dataTransferManager = Windows.ApplicationModel.DataTransfer.DataTransferManager.getForCurrentView();
    dataTransferManager.addEventListener("datarequested", winStoreShareLinkHandler);

    baseScript = document.createElement("script");
    baseScript.src = "//Microsoft.WinJS.1.0/js/base.js";
    baseScript.onload = function () {
        WinJS.Application.onsettings = function (e) {
            var privacyCmd = new Windows.UI.ApplicationSettings.SettingsCommand("privacy", "Privacy Policy", function () {
                window.open("https://www.choiceofgames.com/privacy-policy");
            });
            e.detail.e.request.applicationCommands.append(privacyCmd);
        };
        WinJS.Application.start();
    };
    document.head.appendChild(baseScript);

    uiScript = document.createElement("script");
    uiScript.src = "//Microsoft.WinJS.1.0/js/ui.js";
    document.head.appendChild(uiScript);
} else if (window.isWinOldApp) {
    console = {
        log: function (message) { window.external.ConsoleLog(message); },
        error: function (message) { window.external.ConsoleError(message); }
    };
    document.oncontextmenu = function() {return false;};
}

function platformCode() {
  var platform = "unknown";
  if (window.isIosApp) platform = "ios";
  else if (window.isAndroidApp) platform = "android";
  else if (window.isMacApp) platform = "mac";
  else if (window.isWinStoreApp) platform = "windows";
  else if (window.isWinOldApp) platform = "csharp";
  else if (window.isChromeApp) platform = "chrome";
  else if (window.isWebOS) platform = "palm";
  else if (window.isSteamApp) platform = "steam";
  else if (window.isCef) platform = "cef";
  else if (window.isNode) platform = "dl";
  else if (window.isWeb) platform = "web";
  if (window.isOmnibusApp) platform = "omnibus-" + platform;
  return platform;
}

function reinjectNavigator() {
  if (window.stats && window.stats.scene && window.stats.scene.nav) {
    var scene = window.stats.scene;
    scene.nav = window.nav;
    nav.repairStats(scene.stats);
  }
}
/*
 * Copyright 2010 by Dan Fabulich.
 *
 * Dan Fabulich licenses this file to you under the
 * ChoiceScript License, Version 1.0 (the "License"); you may
 * not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *  http://www.choiceofgames.com/LICENSE-1.0.txt
 *
 * See the License for the specific language governing
 * permissions and limitations under the License.
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
 * either express or implied.
 */
function Scene(name, stats, nav, options) {
    if (!name) name = "";
    if (!stats) stats = {};
    // the name of the scene
    this.name = name;

    // the permanent statistics and the temporary values
    this.stats = stats;
    // implicit_control_flow controls whether goto is necessary to leave options (true means no)
    // _choiceEnds stores the line numbers to jump to when choice #options end.
    this.temps = {choice_reuse:"allow", choice_user_restored:false, _choiceEnds:{}, _looplimit: 1000};

    // the navigator determines which scene comes next
    this.nav = nav;

    options = options || {};

    // should we print debugging information?
    this.debugMode = options.debugMode || false;

    // used for stats screen, and maybe other secondary views someday
    this.secondaryMode = options.secondaryMode;

    this.saveSlot = options.saveSlot || "";

    // the array of lines in the scene file
    this.lines = [];

    // the current line number (WARNING 0-based!)
    this.lineNum = 0;
    this.rollbackLineCoverage();

    // when this is true, the main printLoop will halt
    this.finished = false;

    // map of label names to line numbers
    this.labels = {};

    // the current amount of indentation
    this.indent = 0;

    // Did the previous line contain text?
    this.prevLine = "empty";

    // Have we ever printed any text?
    this.screenEmpty = true;

    // Have we run any commands (except for create and scene_list) yet?
    this.initialCommands = true;

    this.stats.sceneName = name;

    // for easy reachability from the window
    this.stats.scene = this;

    // where should we print text?
    this.target = null;

    this.accumulatedParagraph = [];
}

Scene.prototype.reexecute = function reexecute() {
  this.lineNum = this.stats.testEntryPoint || 0;
  this.finished = 0;
  this.indent = this.getIndent(this.lines[this.lineNum]);
  this.prevLine = "empty";
  this.screenEmpty = true;
  this.execute();
};

// the main loop of the scene
Scene.prototype.printLoop = function printLoop() {
    var line;
    for (;!this.finished && this.lineNum < this.lines.length; this.lineNum++) {
        line = this.lines[this.lineNum];
        if (!trim(line)) {
            this.paragraph();
            continue;
        }
        var indent = this.getIndent(line);
        if (indent > this.indent) {
            // ignore indentation level of *comments
            if (/\s*\*comment\b/.test(line)) continue;
            throw new Error(this.lineMsg() + "increasing indent not allowed, expected " + this.indent + " was " + indent);
        } else if (indent < this.indent) {
            this.dedent(indent);
        }
        // Ability to end a choice #option without goto is guarded by implicit_control_flow variable
        if (this.temps._choiceEnds[this.lineNum] &&
                (this.getVar("implicit_control_flow") || this.temps._fakeChoiceDepth > 0)) {
            // Skip to the end of the choice if we hit the end of an #option
            this.rollbackLineCoverage();
            this.lineNum = this.temps._choiceEnds[this.lineNum];
            this.rollbackLineCoverage();
            if (this.temps._fakeChoiceDepth > 0) {
                this.temps._fakeChoiceDepth--;
            }
            continue;
        }
        this.indent = indent;
        if (/^\s*#/.test(line)) {
            throw new Error(this.lineMsg() + "It is illegal to fall out of a *choice statement; you must *goto or *finish before the end of the indented block.");
        }
        if (!this.runCommand(line)) {
            this.prevLine = "text";
            this.screenEmpty = false;
            this.initialCommands = false;
            this.printLine(line);
        }
    }
    this.rollbackLineCoverage();
    if (!this.finished) {
        this.autofinish();
    }
    this.save("temp");
    if (this.skipFooter) {
        this.skipFooter = false;
    } else {
        printFooter();
    }
};

Scene.prototype.dedent = function dedent(newDent) {};

Scene.prototype.printLine = function printLine(line) {
    if (!line) return null;
    this.screenEmpty = false;
    line = this.replaceVariables(line.replace(/^\s*/, ""));
    this.accumulatedParagraph.push(line);
    // insert extra space unless the line ends with hyphen or dash
    if (!/([-\u2011-\u2014]|\[c\/\])$/.test(line)) this.accumulatedParagraph.push(' ');
};

Scene.prototype.replaceVariables = function (line) {
  line = String(line);
  var replacer = /([$@](\!?\!?)\{)/;
  var index = 0;
  var output = [];
  for (var result = replacer.exec(line); result; result = replacer.exec(line.substring(index))) {
    output.push(line.substring(index, index + result.index));
    var curlies = 0;
    var closingCurly = -1;
    var exprStart = index + result.index + result[1].length;
    for (var i = exprStart; i < line.length; i++) {
      var c = line.charAt(i);
      if (c === "{") {
        curlies++;
      } else if (c === "}") {
        if (curlies) {
          curlies--;
        } else {
          closingCurly = i;
          break;
        }
      }
    }
    if (closingCurly == -1) {
      throw new Error(this.lineMsg() + "invalid "+result[0]+"} variable substitution at letter " + (index + result.index + 1));
    }
    var body = line.substring(exprStart, closingCurly);
    var stack, value;
    if (result[0].charAt(0) === "$") {
      stack = this.tokenizeExpr(body);
      value = this.evaluateExpr(stack);
    } else {
      var expr;
      var options;
      if (/^\s*\(/.test(body)) {
        var parens = 0;
        var closingParen = -1;
        for (var i = 1; i < body.length; i++) {
          var c = body.charAt(i);
          if (c === "(") {
            parens++;
          } else if (c === ")") {
            if (parens) {
              parens--;
            } else {
              closingParen = i;
              break;
            }
          }
        }
        if (closingParen == -1) {
          throw new Error(this.lineMsg() + "invalid "+result[0]+"} at letter " + (index + result.index + 1) + "; missing closing parenthesis )");
        }
        if (body.charAt(closingParen+1) != " ") {
          throw new Error(this.lineMsg() + "invalid "+result[0]+"} at letter " + (index + result.index + 1) + "; there should be a space after the closing parenthesis )");
        }
        expr = body.substring(1, closingParen);
        options = body.substring(closingParen+2).split("|");
      } else {
        if (!/^\S+ /.test(body)) {
          throw new Error(this.lineMsg() + "invalid "+result[0]+"} at letter " + (index + result.index + 1) + "; there should be a space after the first word");
        }
        var spaceIndex = body.indexOf(' ');
        expr = body.substring(0, spaceIndex);
        options = body.substring(spaceIndex+1).split("|");
      }
      if (options.length < 2) {
        throw new Error(this.lineMsg() + "invalid "+result[0]+"} at letter " + (index + result.index + 1) + "; there should be at least one pipe | to separate options");
      }
      stack = this.tokenizeExpr(expr);
      value = this.evaluateExpr(stack);
      if (typeof value === "boolean" || /^(true|false)$/i.test(value)) {
        value = bool(value) ? 1 : 2;
      }
      value = num(value, this.lineNum+1, this.name);
      if ((value | 0) !== value) {
        throw new Error(this.lineMsg() + "invalid "+result[0]+"} at letter " + (index + result.index + 1) + "; '"+expr+"' is equal to " + value + " which is not a whole integer number");
      } else if (value < 1) {
        throw new Error(this.lineMsg() + "invalid "+result[0]+"} at letter " + (index + result.index + 1) + "; '"+expr+"' is equal to " + value + " which is not a positive number");
      } else if (value > options.length) {
        throw new Error(this.lineMsg() + "invalid "+result[0]+"} at letter " + (index + result.index + 1) + "; '"+expr+"' is equal to " + value + " but there are only " + options.length + " options");
      }
      value = options[value-1];
      value = this.replaceVariables(value);
    }
    var capitalize = result[2];
    if (capitalize) value = String(value);
    if (capitalize == "!") {
      value = value.charAt(0).toUpperCase() + value.slice(1);
    } else if (capitalize == "!!") {
      value = value.toUpperCase();
    }
    if (typeof highlightGenderPronouns != "undefined" && highlightGenderPronouns && /\b(he|him|his|she|her|hers)\b/gi.test(value)) {
      // this zero-width space will give us a hint for highlighting
      output.push("\u200b");
    }
    output.push(value);
    index = closingCurly+1;
  }
  if (index === 0) return line;
  output.push(line.substring(index));
  return output.join("");
};

Scene.prototype.paragraph = function paragraph() {
    printParagraph(this.accumulatedParagraph.join(""));
    this.accumulatedParagraph = [];
    this.prevLine = "empty";
};

Scene.prototype.loadSceneFast = function loadSceneFast(url) {
    if (this.loading) return;
    this.loading = true;
    var result;
    var self = this;
    if (typeof cachedResults != "undefined" && cachedResults && cachedResults[this.name]) {
      result = window.cachedResults[this.name];
      return safeTimeout(function() {self.loadLinesFast(result.crc, result.lines, result.labels);}, 0);
    } else if (typeof allScenes != "undefined") {
      result = allScenes[this.name];
      if (!result) throw new Error("Couldn't load scene '" + this.name + "'\nThe file doesn't exist.");
      return safeTimeout(function() {self.loadLinesFast(result.crc, result.lines, result.labels);}, 0);
    } else if (typeof window != "undefined" && window.isIosApp && window.isFile && !window.isOmnibusApp) {
      startLoading();
      var startedWaiting = new Date().getTime();

      function retryScenes(event, command) {
        if (!command) command = "retryscenes";
        clearScreen(function() {
          startLoading();
          if (command == "retryscenes") curl();
          window.downloadState = null;
          callIos(command);
          startedWaiting = new Date().getTime();
          awaitAllScenes();
        });
      }

      function awaitAllScenes() {
        if (typeof allScenes != "undefined") {
          result = allScenes[self.name];
          if (!result) throw new Error("Couldn't load scene '" + self.name + "'\nThe file doesn't exist.");
          self.loadLinesFast(result.crc, result.lines, result.labels);
        } else if (window.downloadState == "failed" || (new Date().getTime() - startedWaiting) > 5000) {
          doneLoading();
          if (window.downloadRequired) {
            self.printLine("We weren't able to download the latest version of the game.");
            self.paragraph();
            printButton("Try Again", main, false, retryScenes);
          } else {
            self.printLine("We weren't able to download the latest version of the game. Please try downloading again. The latest version may contain important fixes.");
            self.paragraph();
            var retry = {name: "Try downloading again."};
            var ignore = {name: "Continue playing without the latest version."}
            printOptions([""], [retry, ignore], function(option) {
              if (option == retry) {
                retryScenes();
              } else {
                retryScenes(null, "requestscenesforce");
              }
            });
          }
        } else {
          setTimeout(awaitAllScenes, 0);
        }
      }
      return awaitAllScenes();
    } else if (window.purchases[self.name] && isStoreSceneCacheRequired()) {
      var sceneName = this.name.replace(/ /g, "_");
      return window.store.get("cache_scene_hash_"+sceneName, function(ok, hash) {
        function keepScene(result) {
          if (!window.cachedResults) window.cachedResults = {};
          cachedResults[self.name] = result;
          self.loadLinesFast(result.crc, result.lines, result.labels);
        }
        function loadPaidScene() {
          startLoading();
          updateSinglePaidSceneCache(self.name, function(err, result) {
            doneLoading();
            if (err) {
              if (err === "not registered") {
                logout();
                loginDiv();
                return clearScreen(function() {
                  loginForm(main, 0/*optional*/,
                    "Please sign in to access this part of the game.", function() {
                      clearScreen(loadAndRestoreGame);
                    });
                });
              } else if (err === "not purchased") {
                window.rerestore = function () {
                  restorePurchases(window.purchases[self.name], function (purchased) {
                    window.location.reload(); 
                  });
                };
                main.innerHTML = "<div id='text'><p>Our apologies; we were unable to access your purchase while loading game data. (Error 403x)" +
                  "  Please restore purchases now; if that doesn't work, please email " + getSupportEmail() + " with details, including the error number 403x.</p>" +
                  " <p><button class='next' onclick='window.rerestore();'>Restore Now</button></p></div>";
                curl();
                return;
              } else if (/^409-/.test(err)) {
                main.innerHTML = "<div id='text'><p>There was a " + err + " error while loading game data." +
                  "  This error occurs when you've logged into this app with a different choiceofgames.com account from the account you originally used to buy the game." +
                  "  You can resolve this error by going to Settings, signing out, and signing in as the account that originally purchased the game." +
                  "  If you need additional assistance, you can email us at " + getSupportEmail() + " and mention error code " + err + ".</p>"
                curl();
                return;
              }
              main.innerHTML = "<div id='text'><p>Our apologies; there was a " + err + " error while loading game data."+
              "  Please refresh now; if that doesn't work, please click the Restart button and email "+getSupportEmail()+" with details, including the error number.</p>"+
              " <p><button class='next' onclick='window.location.reload();'>Refresh Now</button></p></div>";
              curl();
            } else {
              keepScene(result);
            }
          })
        }
        if (ok && hash == hashes.scenes[sceneName + ".txt.json"]) {
          window.store.get("cache_scene_"+sceneName, function(ok, text) {
            if (ok && text) {
              var parsed;
              try {
                parsed = jsonParse(text);
              } catch (e) {
                if (window.console) console.error(e, e.stack);
              }
              if (parsed && parsed.crc && parsed.lines && parsed.labels) return keepScene(parsed);
              loadPaidScene();
            } else {
              loadPaidScene();
            }
          })
        } else {
          loadPaidScene();
        }
      });
    }
    startLoading();
    if (!url) {
        var fileName = this.name.replace(/ /g, "_") + ".txt.json";
        url = Scene.baseUrl + "/" + fileName;
        if (window.location.protocol == "https:" && window.hashes && window.hashes.scenes[fileName]) {
          url += "?hash="+hashes.scenes[fileName];
        }
    }
    var xhr = findXhr();
    xhr.open("GET", url, true);
    var self = this;
    var done = false;
    xhr.onreadystatechange = function() {
        if (done) return;
        if (xhr.readyState != 4) return;
        if (xhr.status == 403) {
          try {
            var err = JSON.parse(xhr.responseText);
            if (err.error == "not registered") {
              return isRegistered(function(registered) {
                if (registered) {
                  logout();
                  loginDiv();
                }
                return clearScreen(function() {
                  loginForm(main, 0/*optional*/,
                    "Please sign in to access this part of the game.", function() {
                      clearScreen(loadAndRestoreGame);
                    });
                });
              });
            }
          } catch (e) {} // JSON parse failure? must not be a login prompt
        }
        done = true;

        var result;
        try {
          result = jsonParse(xhr.responseText);
        } catch (e) {
          if (window.console) console.error(e, e.stack);
        }
        if (window.isWeb && (xhr.status != 200 || !result)) {
          doneLoading();
          var status = xhr.status;
          if (status == 200 || !status) status = "network";
          main.innerHTML = "<div id='text'><p>Our apologies; there was a " + status + " error while loading game data."+
          "  Please refresh your browser now; if that doesn't work, please click the Restart button and email "+getSupportEmail()+" with details.</p>"+
          " <p><button onclick='window.location.reload();'>Refresh Now</button></p></div>";
          curl();
          return;
        } else if (xhr.responseText === "") {
          throw new Error("Couldn't load " + url + "\nThe file is probably missing or empty.");
        }

        if (!window.cachedResults) window.cachedResults = {};
        cachedResults[self.name] = result;
        self.loadLinesFast(result.crc, result.lines, result.labels);
    };
    if (isIE) {
      // IE8 swallows errors in onreadystatechange if xhr.send is in a try block
      xhr.send(null);
    } else {
      try {
        xhr.send(null);
      } catch (e) {
        if (window.location.protocol == "file:" && !window.isMobile) {
          if (/Chrome/.test(navigator.userAgent)) {
            window.reportError("We're sorry, Google Chrome has blocked ChoiceScript from functioning.  (\"file:\" URLs cannot "+
            "load files in Chrome.)  ChoiceScript works just fine in Chrome, but only on a published website like "+
            "choiceofgames.com.  For the time being, please try another browser like Mozilla Firefox.");
            return;
          }
        }
        window.reportError("Couldn't load URL: " + url + "\n" + e);
      }
    }
};

Scene.prototype.loadLinesFast = function loadLinesFast(crc, lines, labels) {
  this.crc = crc;
  this.lines = lines;
  this.labels = labels;
  this.loading = false;
  this.loaded = true;
  var self = this;
  if (this.executing) {
    safeCall(this, function() {
      doneLoading();
      self.execute();
    });
  }
};

// load the scene file from the specified URL (or from default URL by name)
Scene.prototype.loadScene = function loadScene() {
    if (this.loading) return;
    this.loading = true;
    if (window.isFile) return this.loadFile();
    startLoading();
    var url = Scene.baseUrl + "/" + this.name + ".txt";
    var xhr = findXhr();
    xhr.open("GET", url, true);
    var self = this;
    var done = false;
    xhr.onreadystatechange = function() {
        if (done) return;
        if (xhr.readyState != 4) return;
        done = true;
        if (xhr.status == 403) {
          try {
            var err = JSON.parse(xhr.responseText);
            if (err.error == "not registered") {
              return isRegistered(function(registered) {
                if (registered) {
                  logout();
                  loginDiv();
                }
                return clearScreen(function() {
                  loginForm(main, 0/*optional*/,
                    "Please sign in to access this part of the game.", function() {
                      clearScreen(loadAndRestoreGame);
                    });
                });
              });
            }
          } catch (e) {} // JSON parse failure? must not be a login prompt
        }
        if (window.isWeb && xhr.status != 200) {
            var status = xhr.status || "network";
            main.innerHTML = "<p>Our apologies; there was a " + status + " error while loading game data."+
            "  Please refresh your browser now; if that doesn't work, please email "+getSupportEmail()+" with details.</p>"+
            " <p><button onclick='window.location.reload();'>Refresh Now</button></p>";
            return;
        } else if (xhr.responseText === "") {
          if (window.location.protocol == "file:" && !window.isMobile && /Chrome/.test(navigator.userAgent)) {
            window.reportError("We're sorry, Google Chrome has blocked ChoiceScript from functioning.  (\"file:\" URLs cannot "+
            "load files in Chrome.)  ChoiceScript works just fine in Chrome, but only on a published website like "+
            "choiceofgames.com.  For the time being, please try another browser like Mozilla Firefox.");
            return;
          } else {
            window.reportError("Couldn't load " + url + "\nThe file is probably missing or empty.");
            return;
          }
        }
        var result = xhr.responseText;
        scene = result;
        scene = scene.replace(/\r/g, "");
        this.loading = false;
        self.loadLines(scene);
        if (self.executing) {
            safeCall(self, function () {
              doneLoading();
              self.execute();
            });
        }
    };
    if (isIE) {
      // IE8 swallows errors in onreadystatechange if xhr.send is in a try block
      xhr.send(null);
    } else {
      try {
        xhr.send(null);
      } catch (e) {
        if (window.location.protocol == "file:" && !window.isMobile) {
          if (/Chrome/.test(navigator.userAgent)) {
            window.reportError("We're sorry, Google Chrome has blocked ChoiceScript from functioning.  (\"file:\" URLs cannot "+
            "load files in Chrome.)  ChoiceScript works just fine in Chrome, but only on a published website like "+
            "choiceofgames.com.  For the time being, please try another browser like Mozilla Firefox.");
            return;
          } else if (e.code === 1012 /*NS_ERROR_DOM_BAD_URI*/) {
            window.reportError("Couldn't load scene file: " + url + "\nThe file is probably missing.");
            return;
          }
        }
        window.reportError("Couldn't load URL: " + url + "\n" + e);
      }
    }
};

Scene.prototype.loadFile = function loadFile() {
  var _this = this;
  if (typeof uploadedFiles !== "object") {
    clearScreen(function() {
      var header = document.getElementById('header');
      if (header) header.style.display = "none";
      var makeYourOwnGames = document.getElementById('makeyourowngames');
      if (makeYourOwnGames) makeYourOwnGames.style.display = "none";
      _this.printLine("[b]Please \"Upload\" ChoiceScript[/b]");
      _this.paragraph();
      _this.printLine("To begin, you'll need to grant permission to \"upload\" your choicescript folder containing index.html.")
      _this.paragraph();
      _this.printLine("Use the button below to select your choicescript folder.");
      _this.paragraph();
      var text = document.getElementById('text');
      var input = document.createElement('input');
      input.type = 'file';
      input.webkitdirectory = true;
      input.multiple = true;
      input.addEventListener('change', function searchForStartup(e) {
        var numFiles = input.files.length;
        var startupCandidates = [];
        for (var i = 0; i < numFiles; i++) {
          var file = input.files[i];
          if (file.name === "startup.txt") {
            startupCandidates.push(file);
          }
        }
        if (!startupCandidates.length) {
          return clearScreen(function() {
            _this.printLine("We couldn't find startup.txt in the folder you chose. Please try again.")
            _this.paragraph();
            document.getElementById('text').appendChild(input);
            curl();
          });
        }

        if (startupCandidates.length > 1) {
          return clearScreen(function() {
            _this.printLine("There were multiple files called startup.txt in the folder you chose. Please try again.");
            _this.paragraph();
            for (var i = 0; i < startupCandidates.length; i++) {
              _this.printLine("\u2022 " + startupCandidates[i].webkitRelativePath);
            }
            _this.paragraph();
            document.getElementById('text').appendChild(input);
            curl();
          });
        }

        var startup = startupCandidates[0];
        var rootDirTest = new RegExp("^" + startup.webkitRelativePath.replace(/\/startup.txt$/, "/[^/]+$"));
        var sceneFiles = {};
        for (var i = 0; i < numFiles; i++) {
          var file = input.files[i];
          if (rootDirTest.test(file.webkitRelativePath)) {
            sceneFiles[file.name] = file;
          }
        }
        window.uploadedFiles = sceneFiles;
        if (header) header.style.display = "";
        if (makeYourOwnGames) makeYourOwnGames.style.display = "";
        clearScreen(function() {
          _this.loadFile();
        });
      });
      text.appendChild(input);
      _this.paragraph();
      _this.printLine("(We're not actually going to transfer your code over the Internet, " +
        "but this web page needs permission to upload your choicescript folder in order to access " +
        "your code and run it. The power to access your code would also grant us the power to " +
        "transfer your code elsewhere, but we're not going to do that. JavaScript programmers " +
        "can read our JavaScript to verify that this is true.)");
      _this.paragraph();
      curl();
    });
  } else {
    var fileName = this.name + ".txt";
    if (uploadedFiles[fileName]) {
      startLoading();
      new Response(uploadedFiles[fileName]).text().then(function(result) {
        scene = result;
        scene = scene.replace(/\r/g, "");
        _this.loading = false;
        safeCall(_this, function() {
          _this.loadLines(scene);
          doneLoading();
          _this.execute();
        });
      });
    } else {
      for (var otherFileName in uploadedFiles) {
        if (fileName.toLowerCase() === otherFileName.toLowerCase()) {
          main.innerHTML = "<p>Couldn't find "+fileName+" in the uploaded folder, but we did find "+otherFileName+". Scene file names must match exactly, including capitalization.</p>"+
          " <p><button onclick='window.location.reload();'>Refresh Now</button></p>";
          curl();
          return;
        }
      }
      main.innerHTML = "<p>Couldn't find "+fileName+" in the uploaded folder.</p>"+
        " <p><button onclick='window.location.reload();'>Refresh Now</button></p>";
      curl();
    }
  }
}

Scene.prototype.checkSum = function checkSum() {
  if (this.temps.choice_crc) {
    if (!this.randomtest && !this.quicktest && this.temps.choice_crc != this.crc && this.lineNum) {
      // The scene has changed; restart the scene from backup
      if (typeof alertify !== 'undefined') {
        if (!initStore()) {
          alertify.log(this.name + ".txt has updated. Restarting chapter.");
        } else {
          alertify.log("The game has updated. Restarting chapter.");
        }
      }
      var self = this;
      safeTimeout(function () {
        clearScreen(function () {
          loadAndRestoreGame("backup");
        });
      }, 0);
      return false;
    }
  }
  this.temps.choice_crc = this.crc;
  return true;
};

Scene.prototype.loadLines = function loadLines(str) {
    this.crc = crc32(str);
    this.lines = str.split(/\r?\n/);
    this.parseLabels();
    this.loaded = true;
};

// launch the vignette as soon as it's available
Scene.prototype.execute = function execute() {
    if (!this.loaded) {
        this.executing = true;
        if (Scene.generatedFast || (typeof generatedFast != "undefined" && generatedFast) || typeof allScenes != 'undefined') {
          this.loadSceneFast();
        } else {
          this.loadScene();
        }
        return;
    }
    if (!this.checkSum()) {
      return;
    }
    if (this.nav) this.nav.repairStats(stats);
    if (!this.temps._choiceEnds) this.temps._choiceEnds = {};
    doneLoading();
    if (typeof this.targetLabel != "undefined") {
      var label = this.targetLabel.label.toLowerCase();
      if (typeof(this.labels[label]) != "undefined") {
          this.lineNum = this.labels[label];
          this.indent = this.getIndent(this.lines[this.lineNum]);
          delete this.targetLabel;
      } else {
          throw new Error(this.targetLabel.origin + " line " + (this.targetLabel.originLine+1) + ": "+this.name+" doesn't contain label " + label);
      }
    }
    // this backup slot will only be used when the scene crc changes during upgrades
    if (!this.lineNum) {
      var subsceneStack = this.stats.choice_subscene_stack || [];
      if (!subsceneStack.length) this.save("backup");
    }
    if (this.redirectingFromStats) {
      this.save("");
      delete this.redirectingFromStats;
    }
    this.printLoop();
};

// loop through the file looking for *label commands
Scene.prototype.parseLabels = function parseLabels() {
    var lineLength = this.lines.length;
    var oldLineNum = this.lineNum;
    var screenshots = ("choicescript_screenshots" == this.name);
    var seenChoiceWithoutSet = 0;
    for (this.lineNum = 0; this.lineNum < lineLength; this.lineNum++) {
        this.rollbackLineCoverage();
        var line = this.lines[this.lineNum];
        // strip byte order mark
        if (this.lineNum == 0 && line.charCodeAt(0) == 65279) lines[0] = line.substring(1);
        var invalidCharacter = line.match(/^(.*)\ufffd/);
        if (invalidCharacter) throw new Error(this.lineMsg() + "invalid character. (ChoiceScript text should be saved in the UTF-8 encoding.) " + invalidCharacter[0]);
        var result = /^(\s*)\*(\w+)(.*)/.exec(line);
        if (!result) continue;
        var indentation = result[1];
        var indent = indentation.length;
        var command = result[2].toLowerCase();
        var data = trim(result[3]);
        if ("label" == command) {
            data = data.toLowerCase();
            if (/\s/.test(data)) throw new Error(this.lineMsg() + "label '"+data+"' is not allowed to contain spaces");
            if (this.labels.hasOwnProperty(data)) {
              throw new Error(this.lineMsg() + "label '"+data+"' already defined on line " + (this.labels[data]*1+1));
            }
            this.labels[data] = this.lineNum;
        } else if (screenshots) {
          if ("fake_choice" == command) {
            if (seenChoiceWithoutSet) throw new Error(this.lineMsg() +
              "In choicescript_screenshots, you need to *set at least one variable between *fake_choice commands, so the stat screen looks interesting. " +
              "There was no *set since the last *fake_choice on line " + seenChoiceWithoutSet + ".");
            seenChoiceWithoutSet = this.lineNum+1;
          } else if ("set" == command) {
            seenChoiceWithoutSet = 0;
          }
        }
    }
    this.rollbackLineCoverage();
    this.lineNum = oldLineNum;
};

// if this is a command line, run it
Scene.prototype.runCommand = function runCommand(line) {
    var result = /^\s*\*(\w+)(.*)/.exec(line);
    if (!result) {
      if (this.secondaryMode == "startup" && this.startupCallback) {
        this.finished = true;
        this.skipFooter = true;
        this.startupCallback();
        return true;
      }
      return false;
    }
    var command = result[1].toLowerCase();
    var data = trim(result[2]);
    if (Scene.validCommands[command]) {
        if ("comment" == command) return true;
        if (Scene.initialCommands[command]) {
          if ("startup" != String(this.name).toLowerCase() || !this.initialCommands) {
            throw new Error(this.lineMsg() + "Invalid "+command+" instruction, only allowed at the top of startup.txt");
          }
        } else {
          if (this.secondaryMode == "startup" && this.startupCallback) {
            this.finished = true;
            this.skipFooter = true;
            this.startupCallback();
            return true;
          }
          // if we're here, we're running a non-initial command
          // except, *bug choice_beta can appear in the initial commands
          if (command !== 'bug') this.initialCommands = false;
        }
        if (command == "choice" && String(this.name).toLowerCase() == "choicescript_screenshots") {
          throw new Error(this.lineMsg() + "choicescript_screenshots files should only contain *fake_choice commands, not real *choice commands");
        }
        this[command](data);
    } else {
        throw new Error(this.lineMsg() + "Non-existent command '"+command+"'");
    }
    return true;
};

// *choice [group1] [group2] ...
// prompt the user with a multiple choice question.
// nested lines are options to be presented to the user
//
// Examples:
// *choice
//    good
//      Good choice
//      *finish
//    bad
//      Bad choice
//      *finish
//
// *choice toy
//    spaceship
//      Nice spaceship
//      *finish
//    train
//      Nice train
//      *finish
//    doll
//      Nice doll
//      *finish
//
// *choice color toy
//    red
//      spaceship
//        Nice red spaceship
//        *finish
//      train
//        Nice red train
//        *finish
//    blue
//       spaceship
//         Nice blue spaceship
//        *finish
//       train
//         Nice red train
//        *finish

// If a group is specified, generate a prompt message, e.g. "*choice toy" -> "Select a toy:"
// If no group is specified, don't generate a prompt message
// if multiple groups are specified, allow the user to make multiple choices simultaneously
//   all multi-dimensional choices must be valid (otherwise throw a parse error)
Scene.prototype.choice = function choice(data) {
    var startLineNum = this.lineNum;
    var groups = data.split(/ /);
    for (var i = 0; i < groups.length; i++) {
      if (!/^\w*$/.test(groups[i])) {
        throw new Error(this.lineMsg() + "invalid choice group name: " + groups[i]);
      }
    }
    var options = this.parseOptions(this.indent, groups);
    var self = this;
    this.renderOptions(groups, options, function(option) {
      self.standardResolution(option);
    });
    this.finished = true;
    if (this.temps._fakeChoiceDepth > 0 || this.getVar("implicit_control_flow")) {
      if (!this.temps._choiceEnds) {
        this.temps._choiceEnds = {};
      }
      for (i = 0; i < options.length; i++) {
        this.temps._choiceEnds[options[i].line-1] = this.lineNum;
      }
    }
    this.lineNum = startLineNum;
};

Scene.prototype.fake_choice = function fake_choice(data) {
    if (this.temps._fakeChoiceDepth === undefined) {
        this.temps._fakeChoiceDepth = 0;
    }
    this.temps._fakeChoiceDepth++;
    this.choice(data);
};

Scene.prototype.standardResolution = function(option) {
  var self = this;
  self.lineNum = option.line;
  self.indent = self.getIndent(self.nextNonBlankLine(true/*includingThisOne*/));
  if (option.reuse && option.reuse != "allow") self.temps.choice_used[option.line-1] = 1;
  if (this.nav) this.nav.bugLog.push("#"+(option.line+1) + " " + option.name);

  self.finished = false;
  self.resetPage();
};

Scene.prototype.nextNonBlankLine = function nextNonBlankLine(includingThisOne) {
    var line;
    var i = this.lineNum;
    if (!includingThisOne) i++;
    while(isDefined(line = this.lines[i]) && !trim(line)) {
      i++;
    }
    return line;
};

Scene.prototype.previousNonBlankLineNum = function previousNonBlankLineNum() {
  var line;
  var i = this.lineNum - 1;
  while(isDefined(line = this.lines[i]) && !trim(line)) {
    i--;
  }
  return i;
};


Scene.prototype.resetCheckedPurchases = function resetCheckedPurchases() {
  for (var temp in this.temps) {
    if (/^choice_purchased/.test(temp)) {
      delete this.temps[temp];
    }
  }
};

// reset the page and invoke code after clearing the screen
Scene.prototype.resetPage = function resetPage() {
    var self = this;
    this.resetCheckedPurchases();
    clearScreen(function() {
      // save in the background, eventually
      self.save("");
      self.prevLine = "empty";
      self.screenEmpty = true;
      self.execute();
    });
};

/* The function needs some explaining.
We want the game to be "refreshable," e.g. on the web.
So we only make a "real" autosave as you click "Next"
But if we do it that way, when we visit the stat screen, it's out of date
So we make a "temp" autosave slot, right as the page finishes redrawing,
and the stat screen uses the "temp" autosave to display your current data.
When you refresh the page, the "temp" autosave is rewritten.

If you save stats on the stat screen, they're written into tempStatWrites;
when the stat screen saves, we transfer tempStatWrites back to the main
game (if the main game is running in a separate iframe, e.g. iOS).

If the main game is about to write the main "" slot, we merge the temp
stat writes into the main stats (and clear the stat writes) before
saving.

Thus, stat changes on the stat screen will only be permanently saved when
the player clicks "Next" in the main game, ensuring that the game is still
refreshable.
*/
Scene.prototype.save = function save(slot) {
    if (this.saveSlot) {
      transferTempStatWrites();
    } else {
      if (!slot) {
        slot = "";
        for (var key in tempStatWrites) {
          if (tempStatWrites.hasOwnProperty(key)) {
            this.stats[key] = tempStatWrites[key];
          }
        }
        tempStatWrites = {};
      }

      saveCookie(function() {}, slot, this.stats, this.temps, this.lineNum, this.indent);
    }
};

// *goto labelName
// Go to the line labeled with the label command *label labelName
//
// goto by reference
//   *create foo "labelName"
//   *goto {foo}
Scene.prototype["goto"] = function scene_goto(line) {
    var label;
    if (/[\[\{]/.test(line)) {
      label = this.evaluateReference(this.tokenizeExpr(line));
    } else {
      label = String(line).toLowerCase();
    }
    if (typeof(this.labels[label]) != "undefined") {
        this.lineNum = this.labels[label];
        this.indent = this.getIndent(this.lines[this.lineNum]);
    } else {
        throw new Error(this.lineMsg() + "bad label " + label);
    }
    if (!this.localCoverage) this.localCoverage = {};
    if (this.localCoverage[this.lineNum]) {
        this.localCoverage[this.lineNum]++;
        if (this.temps._looplimit && this.localCoverage[this.lineNum] > this.temps._looplimit) {
            throw new Error(this.lineMsg() + "visited this line too many times (" + this.temps._looplimit + ")");
        }
    } else {
        this.localCoverage[this.lineNum] = 1;
    }
};

Scene.prototype.gosub = function scene_gosub(data) {
    var label = /\S+/.exec(data)[0];
    var rest = data.substring(label.length+1);
    var args = [];
    var stack = this.tokenizeExpr(rest);
    while (stack.length) {
      args.push(this.evaluateValueToken(stack.shift(), stack));
    }
    if (!this.temps.choice_substack) {
      this.temps.choice_substack = [];
    }
    this.temps.choice_substack.push({lineNum: this.lineNum, indent: this.indent});
    // Works exactly the same as gosub_scene, putting args in this.temps.param.
    // This means there's no notion of scope - param acts more like "registers" that
    // get clobbered the next time a sub is called.
    // This may be more intuitive to non-programmers than idea of scope?  Especially
    // if temp normally doesn't follow scoping rules.  gosub_scene can serve this function anyway.
    // The params can be retrieved and put in named temps with "params" command.
    this.temps.param = args;
    this["goto"](label);
};

Scene.prototype.gosub_scene = function scene_gosub_scene(data) {
    if (!this.stats.choice_subscene_stack) {
      this.stats.choice_subscene_stack = [];
    }
    this.stats.choice_subscene_stack.push({name:this.name, lineNum: this.lineNum + 1, indent: this.indent, temps: this.temps});
    this.goto_scene(data, true /*isGosubScene*/);
};

Scene.prototype.params = function scene_params(data) {
    // Name the parameters passed by gosub/gosub_scene.
    // Rules should be the same as for "create."
    // All parameters, even those not named, exposed as param_1, param_2 etc.
    var words = /\w+/.exec(data);
    var nextParamNum = 1;
    this.temps.param_count = this.temps.param.length;
    while (words) {
        var varName = words[0].toLowerCase();
        this.validateVariable(varName);
        if (this.temps.param.length < 1) {
            throw new Error(this.lineMsg() + "No parameter passed for " + varName);
        }
        var paramVal = this.temps.param.shift();
        this.temps[varName] = paramVal;
        this.temps["param_" + nextParamNum] = paramVal;
        nextParamNum++;
        data = data.substring(varName.length+1);
        words = /\w+/.exec(data);
    }
    // All remaining params are anonymous, but you still have to say "params"
    // if you want any of them.
    while (this.temps.param.length > 0) {
        var paramVal = this.temps.param.shift();
        this.temps["param_" + nextParamNum] = paramVal;
        nextParamNum++;
    }
};

Scene.prototype["return"] = function scene_return() {
    var stackFrame;
    if (this.temps.choice_substack && this.temps.choice_substack.length) {
      stackFrame = this.temps.choice_substack.pop();
      this.lineNum = stackFrame.lineNum;
      this.indent = stackFrame.indent;
    } else if (this.stats.choice_subscene_stack && this.stats.choice_subscene_stack.length) {
      stackFrame = this.stats.choice_subscene_stack.pop();
      if (stackFrame.name == this.name) {
        this.temps = stackFrame.temps;
        this.lineNum = stackFrame.lineNum-1;
        this.indent = stackFrame.indent;
        return;
      }
      this.finished = true;
      this.skipFooter = true;
      var scene = new Scene(stackFrame.name, this.stats, this.nav, {debugMode:this.debugMode, secondaryMode:this.secondaryMode, saveSlot:this.saveSlot});
      scene.temps = stackFrame.temps;
      scene.screenEmpty = this.screenEmpty;
      scene.prevLine = this.prevLine;
      scene.lineNum = stackFrame.lineNum;
      scene.indent = stackFrame.indent;
      scene.accumulatedParagraph = this.accumulatedParagraph;
      if (this.randomtest) {
        // pop the stack in randomtest to avoid overflow
        clearScreen(function() {scene.execute()});
      } else {
        scene.execute();
      }
    } else if (!this.temps.choice_substack && !this.stats.choice_subscene_stack) {
      throw new Error(this.lineMsg() + "invalid return; gosub has not yet been called");
    } else {
      throw new Error(this.lineMsg() + "invalid return; we've already returned from the last gosub");
    }

};

// *gotoref expression
// Go to the label identified by the expression
//
// *temp foo
// *set foo "bar"
// *gotoref foo
// Skipped!
// *label bar
Scene.prototype["gotoref"] = function scene_gotoref(expression) {
    var stack = this.tokenizeExpr(expression);
    var value = this.evaluateExpr(stack);
    this["goto"](value);
};


// *finish
// halt the scene
Scene.prototype.finish = function finish(buttonName) {
    this.paragraph();
    this.finished = true;
    var self = this;
    if (this.secondaryMode == "stats") {
      if (typeof window == "undefined") return;
      // In iPad app, the stats screen is always visible
      if (window.isIosApp && window.isIPad) return;

      if (this.screenEmpty) {
        returnFromStats();
        return;
      }
      if (!buttonName) buttonName = "Next";
      buttonName = this.replaceVariables(buttonName);
      printButton(buttonName, main, false,
        function() {
          returnFromStats();
        }
      );
      return;
    }
    var nextSceneName = this.nav && nav.nextSceneName(this.name);
    // if there are no more scenes, then just halt
    if (!nextSceneName) {
        if (!this.secondaryMode) this.ending();
        return;
    }
    if (this.screenEmpty) {
      this.goto_scene(nextSceneName);
      return;
    }
    if (!buttonName) buttonName = "Next Chapter";
    buttonName = this.replaceVariables(buttonName);


    printButton(buttonName, main, false,
      function() {
        safeCall(self, function() {
            var scene = new Scene(nextSceneName, self.stats, self.nav, {debugMode:self.debugMode, secondaryMode:self.secondaryMode});
            scene.resetPage();
        });
      }
    );
    if (this.debugMode) println(computeCookie(this.stats, this.temps, this.lineNum, this.indent));
};

Scene.prototype.autofinish = function autofinish(buttonName) {
  this.finish(buttonName);
};

// *reset
// clear all stats
Scene.prototype.reset = function reset() {
    this.nav.resetStats(this.stats);
    this.stats.scene = this;
};

Scene.prototype.parseGotoScene = function parseGotoScene(data) {
  var sceneName, label, param = [], stack;

  if (/[\[\{]/.test(data)) {
    stack = this.tokenizeExpr(data);
    sceneName = this.evaluateReference(stack, {toLowerCase: false});
    // Labels are required for arguments to avoid ambiguity
    if (stack.length) {
      label = this.evaluateReference(stack);
    }
    while (stack.length) {
      // Arguments when treating gosub_scene like a function call
      param.push(this.evaluateValueToken(stack.shift(), stack));
    }
  } else {
    // scenes and labels can contain hyphens and other non-expression punctuation
    // so we'll try to extract the first two words as the scene and label
    var match = /(\S+)\s+(\S+)\s*(.*)/.exec(data);
    if (match) {
      sceneName = match[1];
      label = match[2];
      stack = this.tokenizeExpr(match[3]);
      while (stack.length) {
        // Arguments when treating gosub_scene like a function call
        param.push(this.evaluateValueToken(stack.shift(), stack));
      }
    } else {
      if (data === "") throw new Error(this.lineMsg() + "missing scene name");
      sceneName = data;
    }
  }
  return {sceneName:sceneName, label:label, param:param};
};

// *goto_scene foo
//
Scene.prototype.goto_scene = function gotoScene(data, isGosubScene) {
    if (!isGosubScene && (this.stats.choice_subscene_stack || []).length) {
      var stackFrame = this.stats.choice_subscene_stack.pop();
      this.warning("You should *return before *goto_scene after *gosub_scene from from " + stackFrame.name + " line " + stackFrame.lineNum);
      delete this.stats.choice_subscene_stack;
    }
    var result = this.parseGotoScene(data);

    if (result.sceneName == this.name) {
      if (typeof result.label === "undefined") {
        this.lineNum = -1; // the printLoop will increment the line number to 0
      } else {
        this["goto"](result.label);
      }
      this.temps = {choice_reuse:"allow", choice_user_restored:false, _choiceEnds:{}};
      this.temps.param = result.param;
      this.initialCommands = true;
      return;
    }

    this.finished = true;
    this.skipFooter = true;
    var scene = new Scene(result.sceneName, this.stats, this.nav, {debugMode:this.debugMode, secondaryMode:this.secondaryMode, saveSlot:this.saveSlot});
    scene.screenEmpty = this.screenEmpty;
    scene.prevLine = this.prevLine;
    scene.accumulatedParagraph = this.accumulatedParagraph;
    if (typeof result.label != "undefined") scene.targetLabel = {label:result.label, origin:this.name, originLine:this.lineNum};
    if (typeof result.param != "undefined") scene.temps.param = result.param;
    if (this.redirectingFromStats) scene.redirectingFromStats = true;
    scene.execute();
};

// *redirect_scene foo
Scene.prototype.redirect_scene = function redirectScene(data) {
  if (this.secondaryMode != "stats") throw new Error(this.lineMsg() + "The *redirect_scene command can only be used from the stats screen.");
  var result = this.parseGotoScene(data);
  this.finished = true;
  this.skipFooter = true;
  var self = this;
  redirectFromStats(result.sceneName, result.label, this.lineNum, function() {
    delete self.secondaryMode;
    delete self.saveSlot;
    self.redirectingFromStats = true;
    self.goto_scene(data);
  });
};

Scene.prototype.product = function product(productId) {
  if (!/^[a-z]+$/.test(productId)) throw new Error(this.lineMsg()+"Invalid product id (only lowercase letters, no numbers or punctuation): " +productId);
  if (this.nav) this.nav.products[productId] = {};
}

Scene.prototype.restore_purchases = function scene_restorePurchases(data) {
  var self = this;
  var target = this.target;
  if (!target) target = document.getElementById('text');
  var button = printButton("Restore Purchases", target, false,
    function() {
      safeCall(self, function() {
          restorePurchases(null, function() {
            self["goto"](data);
            self.finished = false;
            self.resetPage();
          });
      });
    }
  );

  setClass(button, "");
  this.prevLine = "block";
};

Scene.prototype.check_purchase = function scene_checkPurchase(data) {
  this.finished = true;
  this.skipFooter = true;
  var self = this;
  var productList = data.split(/ /);
  for (var i = 0; i < productList.length; i++) {
    var product = productList[i];
    if (!this.nav.products[product] && product != "adfree") {
      throw new Error(this.lineMsg() + "The product " + product + " wasn't declared in a *product command");
    }
  }
  checkPurchase(data, function(ok, result) {
    self.finished = false;
    self.skipFooter = false;
    if (!ok) {
      result = {billingSupported:true};
      self.temps.choice_purchase_error = true;
    }
    result = result || {};
    var products = data.split(/ /);
    var everything = true;
    for (var i = 0; i < products.length; i++) {
      var purchasedProduct = result[products[i]] || false;
      self.temps["choice_purchased_"+products[i]] = purchasedProduct;
      if (!purchasedProduct) everything = false;
    }
    self.temps.choice_purchased_everything = everything;
    self.temps.choice_purchase_supported = !!result.billingSupported;
    self.execute();
  });
};

Scene.prototype.parsePurchase = function parsePurchase(data) {
  var result;
  if (/^\{/.test(data)) {
    try {
      result = JSON.parse(data);
    } catch (e) {
      throw new Error(this.lineMsg() + "Couldn't parse purchase JSON: " + e)
    }
    if (!result.product) {
      throw new Error(this.lineMsg() + "JSON missing product");
    }
    if (!result['goto']) {
      throw new Error(this.lineMsg() + "JSON missing goto");
    }
    if (result.priceGuess && result.discount) {
      throw new Error(this.lineMsg() + "JSON has both top-level priceGuess and discount; there should be one or the other");
    }
    if (!(result.priceGuess || result.discount)) {
      throw new Error(this.lineMsg() + "JSON has neither top-level priceGuess nor discount; there should be one or the other");
    }
    if (result.discount) {
      if (!result.discount.end) throw new Error(this.lineMsg() + "JSON discount doesn't include end");
      result.discount.end = parseDateStringInCurrentTimezone(result.discount.end, this.lineNum + 1);
      if (!result.discount.lowPrice) throw new Error(this.lineMsg() + "JSON discount doesn't include lowPrice");
      if (!/^\$/.test(result.discount.lowPrice)) throw new Error(this.lineMsg() + "lowPrice " + fullPriceGuess + "doesn't start with dollar");
      if (!result.discount.fullPrice) throw new Error(this.lineMsg() + "JSON discount doesn't include fullPrice");
      if (!/^\$/.test(result.discount.fullPrice)) throw new Error(this.lineMsg() + "fullPrice " + fullPriceGuess + "doesn't start with dollar");
    }
    if (!result.title) result.title = "It";
  } else {
    var parsed = /^(\w+)\s+(\S+)\s+(.*)/.exec(data);
    if (!parsed) throw new Error(this.lineMsg() + "invalid line; can't parse purchaseable product: " + data);
    result = {product: parsed[1], priceGuess: parsed[2], "goto": parsed[3], title: "It"};
  }
  return result;
}

Scene.prototype.purchase = function purchase(data) {
  var parsed = this.parsePurchase(data);
  if (parsed.discount) {
    this.buyButtonDiscount(parsed.product, parsed.discount.end, parsed.discount.fullPrice, parsed.discount.lowPrice, parsed['goto'], parsed.title);
  } else {
    this.buyButton(parsed.product, parsed.priceGuess, parsed['goto'], parsed.title);
  }
}

Scene.prototype.buyButton = function(product, priceGuess, label, title) {
  if (label && typeof this.temps["choice_purchased_" + product] === "undefined") {
    throw new Error(this.lineMsg() + "Didn't check_purchases on this page");
  }
  this.finished = true;
  this.skipFooter = true;
  var self = this;
  var purchaseFinished = function() {
    if (label) self["goto"](label);
    self.finished = false;
    self.skipFooter = false;
    self.resetPage();
  }
  getPrice(product, function (price) {
    if (!price || "free" == price) {
      purchaseFinished();
    } else {
      if (price == "guess") price = priceGuess + " USD";
      var prerelease = self.getVar('choice_prerelease');
      var buttonText;
      if (prerelease) {
        buttonText = "Pre-Order " + title;
      } else {
        buttonText = "Buy "+title+" Now";
      }
      if (price != "hide") {
        buttonText += " for " + price;
      }
      var target = self.target;
      if (!target) target = document.getElementById('text');
      self.paragraph();
      var button = printButton(buttonText, target, false,
        function() {
          safeCall(self, function() {
              purchase(product, function() {
                safeCall(self, purchaseFinished);
              });
          });
        }
      );
      self.prevLine = "block";
      if (isRestorePurchasesSupported()) {
        self.prevLine = "text";
        printLink(printParagraph("If you've already purchased, click here to "), "#", "restore purchases",
          function(e) {
            preventDefault(e);
            safeCall(self, function() {
                restorePurchases(product, function(purchased) {
                  if (purchased) {
                    purchaseFinished();
                  } else {
                    // refresh, in case we're on web showing a full-screen login. Not necessary on mobile? But, meh.
                    clearScreen(function() {loadAndRestoreGame("", window.forcedScene);});
                  }
                });
            });
          }
        );
      }

      if (label) {
        self.skipFooter = false;
        self.finished = false;
        self.execute();
      }
    }
  });
};

Scene.prototype.purchase_discount = function purchase_discount(line) {
  this.paragraph();
  var args = trim(String(line)).split(" ");
  if (args.length != 5) throw new Error(this.lineMsg() + "expected five arguments, saw "+args.length+": " + line);
  var product = args[0];
  var expectedEndDateString = args[1];
  var expectedEndDate = parseDateStringInCurrentTimezone(expectedEndDateString, this.lineNum+1);
  var fullPriceGuess = this.replaceVariables(args[2]);
  var discountedPriceGuess = this.replaceVariables(args[3]);
  var label = args[4];
  var startsWithDollar = /^\$/;
  if (!startsWithDollar.test(fullPriceGuess)) {
    throw new Error(this.lineMsg() + "full price guess "+fullPriceGuess+"doesn't start with dollar: " + line);
  }
  if (!startsWithDollar.test(discountedPriceGuess)) {
    throw new Error(this.lineMsg() + "discounted price guess "+discountedPriceGuess+"doesn't start with dollar: " + line);
  }
  var title = "It";
  this.buyButtonDiscount(product, expectedEndDate, fullPriceGuess, discountedPriceGuess, label, title);
}

Scene.prototype.buyButtonDiscount = function buyButtonDiscount(product, expectedEndDate, fullPriceGuess, discountedPriceGuess, label, title) {
  var prerelease = this.getVar('choice_prerelease');
  var discountText;
  if (prerelease) {
    discountText = "[b]Buy now before the price increases![/b]";
  } else {
    discountText = "[b]On sale until "+shortMonthStrings[expectedEndDate.getMonth()+1]+" "+expectedEndDate.getDate()+"! Buy now before the price increases![/b]"
  }
  if (typeof printDiscount != "undefined") {
    printDiscount(product, expectedEndDate.getYear()+1900, expectedEndDate.getMonth()+1, expectedEndDate.getDate(), discountText);
  }
  var priceGuess;
  if (new Date().getTime() < expectedEndDate.getTime()) {
    priceGuess = discountedPriceGuess;
  } else {
    priceGuess = fullPriceGuess;
  }
  this.buyButton(product, priceGuess, label, title);
}

Scene.prototype.print_discount = function print_Discount(line) {
  var result = /(\w+) (\d{4})-(\d{2})-(\d{2}) (.*$)/.exec(line);
  if (!result) throw new Error("invalid discount: " + line);
  var product = result[1];
  var fullYear = result[2];
  var oneBasedMonthNumber = parseInt(result[3],10);
  var dayOfMonth = parseInt(result[4],10);
  var discountText = result[5];
  this.temps.choice_discount_ends = "POISONTOKEN";
  discountText = this.replaceVariables(discountText).replace("POISONTOKEN", "${choice_discount_ends}");
  delete this.temps.choice_discount_ends;
  if (typeof printDiscount != "undefined") printDiscount(product, fullYear, oneBasedMonthNumber, dayOfMonth, discountText);
};

// *abort
// halt the scene without showing a button
Scene.prototype.abort = function() {
  this.paragraph();
  this.finished = true;
};

// *create
// create a new permanent stat
Scene.prototype.create = function create(line) {
    var result = /^(\w*)(.*)/.exec(line);
    if (!result) throw new Error(this.lineMsg() + "Invalid create instruction, no variable specified: " + line);
    var variable = result[1];
    this.validateVariable(variable);
    variable = variable.toLowerCase();
    var expr = result[2];
    var stack = this.tokenizeExpr(expr);
    if (stack.length > 1) throw new Error(this.lineMsg() + "Invalid create instruction, too many values: " + line);
    this.createVariable(variable, stack[0], line);
}

Scene.prototype.createVariable = function createVariable(variable, token, line) {
  var self = this;
  if (!token) throw new Error(this.lineMsg() + "Invalid create instruction, no value specified: " + line);
  function complexError() {
    throw new Error(self.lineMsg() + "Invalid create instruction, value must be a number, true/false, or a quoted string: " + line);
  }
  if (!/STRING|NUMBER|VAR/.test(token.name)) complexError();
  if ("VAR" == token.name && !/^true|false$/i.test(token.value)) complexError();
  if ("STRING" == token.name && /(\$|@)!?!?{/.test(token.value)) throw new Error(this.lineMsg() + "Invalid create instruction, value must be a simple string without ${} or @{}: " + line);
  var value = this.evaluateExpr([token]);
  if (!this.created) this.created = {};
  if (this.created[variable]) throw new Error(this.lineMsg() + "Invalid create. " + variable + " was previously created on line " + this.created[variable]);
  this.created[variable] = this.lineNum + 1;
  this.stats[variable] = value;
  if (this.nav) this.nav.startingStats[variable] = value;
}

// *create_array {name} {length} {value(s)}
// create an "array" of permanent stats:
//    myarray_1
//    myarray_2
//    ...
//    myarray_count
Scene.prototype.create_array = function create(line) {
  this.defineArray("create", line);
};

// *temp
// create a temporary stat for the current scene
Scene.prototype.temp = function temp(line) {
    var result = /^(\w*)(.*)/.exec(line);
    if (!result) throw new Error(this.lineMsg()+"Invalid temp instruction, no variable specified: " + line);
    var variable = result[1];
    this.validateVariable(variable);
    var expr = result[2];
    var stack = this.tokenizeExpr(expr);
    if (stack.length === 0) {
      this.temps[variable.toLowerCase()] = null;
      return;
    }
    var value = this.evaluateExpr(stack);
    if (typeof this.stats[variable.toLowerCase()] !== 'undefined') {
      this.warning("This is a temp, but we already ran *create " + variable);
    }
    this.temps[variable.toLowerCase()] = value;
};

// *temp_array
// create an "array" of temporary stats for the current scene
Scene.prototype.temp_array = function temp_array(line) {
  this.defineArray("temp", line);
};

Scene.prototype.defineArray = function defineArray(command, line) {
  var result = /^(\w+)(.*)/.exec(line);
  if (!result) throw new Error(this.lineMsg() + "Invalid " + command + "_array instruction, no array name specified: " + line);
  var variable = result[1];
  this.validateVariable(variable);
  variable = variable.toLowerCase();
  var stack = this.tokenizeExpr(result[2].trim());
  if (!stack.length) {
    throw new Error(this.lineMsg() + "Invalid " + command + "_array instruction, missing length: " + line);
  }
  var length = Number(stack.shift().value);
  if (length !== Math.floor(length) || length < 1) {
    throw new Error(this.lineMsg() + "Invalid " + command + "_array instruction, length should be a whole number greater than 0: " + line);
  }

  var token;
  var value = null;
  var self = this;
  var i;
  function create(suffix) {
    if (command === "create") {
      self.createVariable(variable + "_" + suffix, token, line);
    } else {
      self.temps[variable + "_" + suffix] = value;
    }
  }

  function next() {
    token = stack[0];
    if (!token) throw new Error(self.lineMsg() + "Too few values. Expected 1 default value or " + length + " explicit values, not " + i + ": " + line);
    value = self.evaluateValueToken(stack.shift(), stack);
  }

  if (stack.length) next();
  
  if (!stack.length) {
    // Use first default value for all creations
    for (i = 0; i < length; i++) {
      create(i+1);
    }
  } else {
    create(1);
    for (i = 1; i < length; i++) {
      next();
      create(i+1);
    }
  }

  if (stack.length) {
    throw new Error(this.lineMsg() + "Too many values. Expected 1 default value or " + length + " explicit values: " + line);
  }

  token = { name: "NUMBER", value: length };
  value = length;
  create("count");
}

// retrieve the value of the variable, preferring temp scope
Scene.prototype.getVar = function getVar(variable) {
    var value;
    variable = String(variable).toLowerCase();
    if (variable && !isNaN(1*variable) && String(1*variable) === variable) return 1*variable;
    if (variable == "true") return true;
    if (variable == "false") return false;
    if (variable == "choice_subscribe_allowed") return true;
    if (variable == "choice_register_allowed") return isRegisterAllowed();
    if (variable == "choice_registered") return typeof window != "undefined" && !!window.registered;
    if (variable == "choice_is_web") return typeof window != "undefined" && !!window.isWeb;
    if (variable == "choice_is_steam") return typeof window != "undefined" && !!window.isSteamApp;
    if (variable == "choice_is_steam_deck") return typeof window != "undefined" && !!window.isSteamApp && !!window.isSteamDeck;
    if (variable == "choice_is_ios_app") return typeof window != "undefined" && !!window.isIosApp;
    if (variable == "choice_is_ipad_app") return typeof window != "undefined" && !!window.isIosApp && !!window.isIPad;
    if (variable == "choice_is_android_app") return typeof window != "undefined" && !!window.isAndroidApp;
    if (variable == "choice_is_omnibus_app") return typeof window != "undefined" && !!window.isOmnibusApp;
    if (variable == "choice_is_amazon_app") return typeof window != "undefined" && !!window.isAmazonApp;
    if (variable == "choice_is_advertising_supported") return typeof isAdvertisingSupported != "undefined" && !!isAdvertisingSupported();
    if (variable == "choice_is_trial") return !!(typeof isTrial != "undefined" && isTrial);
    if (variable == "choice_release_date") {
      if (typeof window != "undefined" && window.releaseDate) {
        return simpleDateFormat(window.releaseDate);
      }
      return "release day";
    }
    if (variable == "choice_prerelease") return typeof isPrerelease != "undefined" && !!isPrerelease();
    if (variable == "choice_kindle") return typeof isKindle !== "undefined" && !!isKindle;
    if (variable == "choice_randomtest") return !!this.randomtest;
    if (variable == "choice_quicktest") return false; // quicktest will explore "false" paths
    if (variable == "choice_linenum") return this.lineNum;
    if (variable == "choice_scene") return this.name;
    if (variable == "choice_restore_purchases_allowed") return isRestorePurchasesSupported();
    if (variable == "choice_save_allowed") return areSaveSlotsSupported();
    if (variable == "choice_time_stamp") return Math.floor(new Date()/1000);
    if (variable == "choice_nightmode") return typeof isNightMode != "undefined" && isNightMode();
    if (variable == "choice_title") {
      if (typeof this.stats.choice_title === "undefined") {
        throw new Error(this.lineMsg() + "This game is missing a *title command");
      }
    }
    if (variable.startsWith("choice_saved_checkpoint")) {
      return !!this.stats[variable];
    }
    if ((!this.temps.hasOwnProperty(variable))) {
        if ((!this.stats.hasOwnProperty(variable))) {
            if (variable == "implicit_control_flow") return false;
            throw new Error(this.lineMsg() + "Non-existent variable '"+variable+"'");
        }
        value = this.stats[variable];
        if (value === null || value === undefined) {
            throw new Error(this.lineMsg() + "Variable '"+variable+"' exists but has no value");
        }
        if (this.debugMode) println("stats["+ variable + "]==" + value);
        return value;
    }
    value = this.temps[variable];
    if (value === null || value === undefined) {
        throw new Error(this.lineMsg() + "Variable '"+variable+"' exists but has no value");
    }
    if (this.debugMode) println("temps["+ variable + "]==" + value);
    return value;
};

// set the value of the variable, preferring temp scope
Scene.prototype.setVar = function setVar(variable, value) {
    variable = variable.toLowerCase();
    if (this.debugMode) println(variable +"="+ value);
    if ("undefined" === typeof this.temps[variable]) {
        if ("undefined" === typeof this.stats[variable]) {
            throw new Error(this.lineMsg() + "Non-existent variable '"+variable+"'");
        }
        this.stats[variable] = value;
        if (this.saveSlot == "temp") tempStatWrites[variable] = value;
        // Implicit control flow flag is ideally set just once in startup.
        // Removing these lines makes this not possible with quicktest.
        if (variable == "implicit_control_flow" && this.nav) {
            this.nav.startingStats["implicit_control_flow"] = value;
        }
    } else {
        this.temps[variable] = value;
    }
};

// *delete variable
// deletes the named variable
Scene.prototype["delete"] = function scene_delete(variable) {
    variable = variable.toLowerCase();
    if ("undefined" === typeof this.temps[variable]) {
        if ("undefined" === typeof this.stats[variable]) {
            throw new Error(this.lineMsg() + "Non-existent variable '"+variable+"'");
        }
        delete this.stats[variable];
    } else {
        delete this.temps[variable];
    }
};

Scene.prototype["delete_array"] = function scene_delete_array(arrayName) {
  arrayName = arrayName.toLowerCase();
  if ("undefined" === typeof this.temps[arrayName + "_count"]) {
      if ("undefined" === typeof this.stats[arrayName + "_count"]) {
          throw new Error(this.lineMsg() + "Non-existent array '"+arrayName+"'");
      }
      var length = this.stats[arrayName + "_count"];
      delete this.stats[arrayName + "_count"];
      for (var i = 1; i <= length; i++) {
        delete this.stats[arrayName + ("_" + i)];
      }
  } else {
      var length = this.temps[arrayName + "_count"];
      delete this.temps[arrayName + "_count"];
      for (var i = 1; i <= length; i++) {
        delete this.temps[arrayName + ("_" + i)];
      }
  }
};

// during a choice, recursively parse the options
Scene.prototype.parseOptions = function parseOptions(startIndent, choicesRemaining, expectedSubOptions) {
    // nextIndent: the level of indentation after the current line
    // For example, in the color/toy sample above, we start at 0
    // then the nextIndent is 2 for "red"
    // then the nextIndent is 4 for "spaceship"
    var nextIndent = null;
    var options = [];
    var choiceEnds = [];
    var line;
    var currentChoice = choicesRemaining[0];
    if (!currentChoice) currentChoice = "choice";
    var suboptionsEncountered = false;
    var bodyExpected = false;
    var previousSubOptions;
    var namesEncountered = {};
    var atLeastOneSelectableOption = false;
    var prevOption, ifResult;
    var startingLine = this.lineNum;
    var self = this;
    function removeModifierCommand(stripParethentical) {
      if (stripParethentical) {
        var openParen = line.indexOf("(")+1;
        var closingParen = matchBracket(line, "()", openParen);
        if (closingParen == -1) {
          throw new Error(self.lineMsg() + "missing closing parenthesis");
        }
        line = trim(line.substr(closingParen+1));
      } else {
        line = trim(line.replace(/^\s*\*(\w+)(.*)/, "$2"));
      }
      parsed = /^\s*\*(\w+)(.*)/.exec(line);
      if (parsed) {
        command = parsed[1].toLowerCase();
        data = trim(parsed[2]);
      } else {
        command = "";
      }
    }
    while(isDefined(line = this.lines[++this.lineNum])) {
        if (!trim(line)) {
            this.rollbackLineCoverage();
            continue;
        }
        var indent = this.getIndent(line);
        if (nextIndent === null || nextIndent === undefined) {
            // initialize nextIndent with whatever indentation the line turns out to be
            // ...unless it's not indented at all
            if (indent <= startIndent) {
                throw new Error(this.lineMsg() + "invalid indent, expected at least one '" + currentChoice + "'");
            }
            this.indent = nextIndent = indent;
        }
        if (indent <= startIndent) {
            // it's over!
            break;
        }
        if (indent < this.indent) {
            // TODO drift detection
            if (false) /*(indent != nextIndent)*/ {
                // error: indentation has decreased, but not all the way back
                // Example:
                // *choice
                //     red
                //   blue
                throw new Error(this.lineMsg() + "invalid indent, expected "+this.indent+", was " + indent);
            }

            // we must be falling out of a sub-block
            this.dedent(indent);
            this.indent = indent;
        }
        if (indent > this.indent) {
            // body of the choice
            // ...unless we haven't identified our choices yet
            // TODO is this error test valid?
            if (choicesRemaining.length>1) throw new Error(this.lineMsg() + "invalid indent, there were subchoices remaining: [" + choicesRemaining.join(",") + "]");
            this.rollbackLineCoverage();
            bodyExpected = false;
            continue;
        }

        // here's the end of the previous option
        if (options.length) {
          prevOption = options[options.length-1];
          if (!prevOption.endLine) prevOption.endLine = this.lineNum;
        }

        // Execute *if commands (etc.) during option loop
        // sub-commands may modify this.indent
        var parsed = /^\s*\*(\w+)(.*)/.exec(line);
        var unselectable = false;
        var inlineIf = null;
        var selectableIf = null;
        var self = this;

        var overrideDefaultReuseSetting = false;
        var reuse = this.temps.choice_reuse;
        if (parsed) {
            var command = parsed[1].toLowerCase();
            var data = trim(parsed[2]);
            // TODO whitelist commands
            if ("hide_reuse" == command) {
              reuse = "hide";
              overrideDefaultReuseSetting = true;
              removeModifierCommand();
            }
            if ("disable_reuse" == command) {
              reuse = "disable";
              overrideDefaultReuseSetting = true;
              removeModifierCommand();
            }
            if ("allow_reuse" == command) {
              reuse = "allow";
              overrideDefaultReuseSetting = true;
              removeModifierCommand();
            }
            if ("random_weight" == command) {
              removeModifierCommand(true /*stripParenthetical*/);
            }

            if ("print" == command) {
                line = this.evaluateExpr(this.tokenizeExpr(data));
            } else if ("if" == command) {
              choiceEnds.push(this.lineNum);
              ifResult = this.parseOptionIf(data, command);
              if (ifResult) {
                inlineIf = ifResult.condition;
                if (ifResult.result) {
                  line = ifResult.line;
                } else {
                  continue;
                }
              } else {
                this["if"](data, true /*inChoice*/);
                continue;
              }
            } else if ("else" == command) {
              if (data) throw new Error(this.lineMsg() + "Invalid content after *" + command + "; move the #option to the next line, indented: " + data);
              this[command](data, true /*inChoice*/);
              continue;
            } else if (/^(elseif|elsif)$/.test(command)) {
              ifResult = this.parseOptionIf(data, command);
              if (ifResult) {
                throw new Error(this.lineMsg() + "Invalid content after *" + command + "; move the #option to the next line, indented: " + ifResult.line);
              }
              this[command](data, true /*inChoice*/);
              continue;
            } else if ("selectable_if" == command) {
              ifResult = this.parseOptionIf(data, command);
              if (!ifResult) throw new Error(this.lineMsg() + "Couldn't parse the line after *selectable_if: " + data);
              line = ifResult.line;
              selectableIf = ifResult.condition;
              unselectable = unselectable || !ifResult.result;
            } else if ("comment" == command) {
                continue;
            } else if (!command) {
              // command was rewritten by earlier modifier
            } else {
                if (Scene.validCommands[command]) {
                  throw new Error(this.lineMsg() + "Invalid indent? Expected an #option here, not *"+command);
                }  else {
                    throw new Error(this.lineMsg() + "Non-existent command '"+command+"'");
                }
            }
        }

        if ("allow" != reuse) {
          if (!this.temps.choice_used) this.temps.choice_used = {};
          if (this.temps.choice_used[this.lineNum]) {
            if ("hide" == reuse) continue;
            unselectable = true;
          }
        }

        // this line should be a valid option
        if (!/^\s*\#\s*\S/.test(line)) {
            throw new Error(this.lineMsg() + "Expected option starting with #");
        }
        // replace variables here and discard the result, so error messages display the correct line
        this.replaceVariables(line);
        line = trim(trim(line).substring(1));
        var option = {name:line, group:currentChoice};
        if (reuse != "allow") option.reuse = reuse;
        if (this.displayOptionConditions) {
          option.displayIf = [];
          for (var i = 0; i < this.displayOptionConditions.length; i++) {
            option.displayIf[i] = this.displayOptionConditions[i];
          }
          if (inlineIf) option.displayIf.push(inlineIf);
        } else if (inlineIf) {
          option.displayIf = [inlineIf];
        }
        if (selectableIf) {
          option.selectableIf = selectableIf;
        }
        option.line = this.lineNum + 1;
        if (unselectable) {
          option.unselectable = true;
        }
        if (namesEncountered[line]) {
            this.conflictingOptions(this.lineMsg() + "Invalid option; conflicts with option '"+option.name+"' on line " + namesEncountered[line]);
        } else {
            namesEncountered[line] = option.line;
        }
        options.push(option);
        if (choicesRemaining.length>1) {
            // recursive call will modify this.indent
            option.suboptions = this.parseOptions(this.indent, choicesRemaining.slice(1), previousSubOptions);
            // now restore it
            this.indent = nextIndent;
            if (!previousSubOptions) previousSubOptions = option.suboptions;
            suboptionsEncountered = true;
        } else {
            bodyExpected = true;
        }
        if (!unselectable) atLeastOneSelectableOption = true;
    }

    // TODO is this error test valid?
    if (choicesRemaining.length>1 && !suboptionsEncountered) {
        throw new Error(this.lineMsg() + "invalid indent, there were subchoices remaining: [" + choicesRemaining.join(",") + "]");
    }
    if (bodyExpected &&
            (this.temps._fakeChoiceDepth === undefined || this.temps._fakeChoiceDepth < 1)) {
        throw new Error(this.lineMsg() + "Expected choice body");
    }
    if (!atLeastOneSelectableOption) this.conflictingOptions(this.lineMsg() + "No selectable options");
    if (expectedSubOptions) {
        this.verifyOptionsMatch(expectedSubOptions, options);
    }
    this.rollbackLineCoverage();
    prevOption = options[options.length-1];
    this.lineNum = this.previousNonBlankLineNum();
    if (!prevOption.endLine) prevOption.endLine = this.lineNum+1;
    for (i = 0; i < choiceEnds.length; i++) {
        this.temps._choiceEnds[choiceEnds[i]] = this.lineNum;
    }
    this.rollbackLineCoverage();
    return options;
};

// compute *if statement during options
Scene.prototype.parseOptionIf = function parseOptionIf(data) {
  var parsed = /^\s*\((.*)\)\s+(#.*)/.exec(data);
  if (!parsed) {
    return;
  }
  var condition = parsed[1];
  var stack = this.tokenizeExpr(condition);
  var result = this.evaluateExpr(stack);
  if (this.debugMode) println(condition + " :: " + result);
  result = bool(result, this.lineNum+1);
  // In the autotester, all conditionals are enabled
  result = result || this.testPath;
  return {result:result, line:parsed[2], condition:null};
};

// Add this as a separate method so we can override it elsewhere
// We want this error during randomtest but not during autotest
// Because autotest makes impossible situations happen
Scene.prototype.conflictingOptions = function conflictingOptions(str) {
  throw new Error(str);
};

// verify that the current option set corresponds to the previous option set
// (for multichoices)
Scene.prototype.verifyOptionsMatch = function verifyOptionsMatch(prev, current) {
    // find matching option by name
    function findMatch(name, options) {
        for (var i = 0; i < options.length; i++) {
            var option = options[i];
            if (option && name == option.name) {
                return option;
            }
        }
        return null;
    }

    var prevOpt, curOpt;
    for (var i = 0; i < prev.length; i++) {
        prevOpt = prev[i];
        curOpt = findMatch(prevOpt.name, current);
        if (!curOpt) throw new Error(this.lineMsg()+"Missing expected suboption '"+prevOpt.name+"'; all suboptions must have same option list");
    }

    if (prev.length == current.length) return;

    for (i = 0; i < current.length; i++) {
        curOpt = current[i];
        prevOpt = findMatch(curOpt.name, prev);
        if (!prevOpt) throw new Error(this.lineMsg()+"Added unexpected suboption '"+curOpt.name+"'; all suboptions must have same option list");
    }

    throw new Error(this.lineMsg()+"Bug? previous options and current options mismatch, but no particular missing element");
};

// render the prompt and the radio buttons
Scene.prototype.renderOptions = function renderOptions(groups, options, callback) {
    var self = this;
    function replaceVars(options) {
      for (var i = 0; i < options.length; i++) {
        var option = options[i];
        option.name = self.replaceVariables(option.name);
        if (option.suboptions) replaceVars(option.suboptions);
      }
    }
    replaceVars(options);
    this.paragraph();
    printOptions(groups, options, callback);

    if (this.debugMode) println(computeCookie(this.stats, this.temps, this.lineNum, this.indent));

    if (this.finished) printFooter();
};

// *page_break
// pause and prompt the user to press "Next"
Scene.prototype.page_break = function page_break(buttonName) {
    if (this.screenEmpty) return;
    if (!buttonName) buttonName = "Next";
    buttonName = this.replaceVariables(buttonName);
    this.paragraph();
    this.finished = true;

    var self = this;
    printButton(buttonName, main, false,
      function() {
        delayBreakEnd();
        self.finished = false;
        self.resetPage();
      }
    );
    if (this.debugMode) println(computeCookie(this.stats, this.temps, this.lineNum, this.indent));
};

Scene.prototype.page_break_advertisement = function pageBreakAdvertisement(line) {
  if (line) throw new Error(this.lineMsg() + "*page_break_advertisement doesn't allow you to change the button message. This text will never be shown: " + line);
  var self = this;
  this.finished = true;
  showFullScreenAdvertisementButton("Watch an Ad to Continue", function () {
    self.finished = false;
    if (self.screenEmpty) {
      self.skipFooter = false;
      self.resetPage();
    } else {
      self.page_break("");
    }
  }, function () {
    delayBreakEnd();
    self.finished = false;
    self.skipFooter = false;
    self.resetPage();
  });
};

Scene.prototype.finish_advertisement = function finishAdvertisement(line) {
  if (line) throw new Error(this.lineMsg() + "*finish_advertisement doesn't allow you to change the button message. This text will never be shown: " + line);
  var self = this;
  this.finished = true;
  showFullScreenAdvertisementButton("Watch an Ad for the Next Chapter", function () {
    self.finish("");
  }, function () {
    var nextSceneName = self.nav && nav.nextSceneName(self.name);
    var scene = new Scene(nextSceneName, self.stats, self.nav, { debugMode: self.debugMode, secondaryMode: self.secondaryMode });
    scene.resetPage();
  });
};

// *line_break
// single line break in the middle of a paragraph
Scene.prototype.line_break = function line_break() {
    // We want to prevent a huge <p><br></p> between blocks
    // so if there's existing text we'll just toss in a [n/]
    // and if there's no text yet, we'll directly insert a <br>
    if (this.accumulatedParagraph.length) {
      this.accumulatedParagraph.push('[n/]');
    } else {
      println();
    }
};

// *image
Scene.prototype.image = function image(data, invert) {
  this.paragraph();
  data = data || "";
  /* Don't apply the regex match to data:image values, it's far too slow */
  var dataImage = false;
  var dataImageVal;
  if (data.match(/data:image/)) {
    var dataImageEnd = data.indexOf(" ");
    if (dataImageEnd >= 0) {
      dataImageVal = data.substring(0, dataImageEnd);
      data = ("CSIDE_DATA_IMG.png" + data.slice(dataImageEnd));
    } else {
      dataImageVal = data;
      data = "CSIDE_DATA_IMG.png";
    }
    dataImage = true;
  }
  data = this.replaceVariables(data);
  var match = /(\S+) (\S+)(.*)/.exec(data);
  var source, alignment;
  var alt = null;
  if (match) {
    var source = match[1];
    var alignment = match[2];
    var alt = trim(match[3]);
  } else {
    source = data;
  }
  if (source === "") throw new Error(this.lineMsg()+"*image requires the file name of an image");
  if (source === "CSIDE_DATA_IMG.png") source = dataImageVal;
  alignment = alignment || "center";
  if (!/(right|left|center|none)/.test(alignment)) throw new Error(this.lineMsg()+"Invalid alignment, expected right, left, center, or none: " + data);
  printImage(source, alignment, alt, invert);
  if (this.verifyImage && !dataImage) this.verifyImage(source);
  if (alignment == "none") this.prevLine = "text";
  this.screenEmpty = false;
};

Scene.prototype.text_image = function textImage(data) {
  this.image(data, "invert");
}

// *sound
// play named sound file
Scene.prototype.sound = function sound(source) {
    if (typeof playSound == "function") playSound(source);
    if (this.verifyImage) this.verifyImage(source);
};

Scene.prototype.kindle_image = function kindle_image() {
  // Do nothing on non-Kindle devices; show only on Kindle
};

Scene.prototype.youtube = function youtube(slug) {
  if (typeof printYoutubeFrame !== "undefined") {
    printYoutubeFrame(slug);
    this.prevLine = "block";
    this.screenEmpty = false;
  }
}

Scene.prototype.kindle_search = Scene.prototype.kindle_product = function kindle_search(data) {
  var result = /^\((.+)\) ([^\)]+)/.exec(data);
  if (!result) throw new Error(this.lineMsg()+"Invalid arguments: " + data);
  var query = result[1];
  var buttonName = result[2];
  if ("undefined" != typeof kindleButton) kindleButton(this.target, query, buttonName);
};

// *link
// Display URL with anchor text
Scene.prototype.link = function link(data) {
    var result = /^(\S+)\s*(.*)/.exec(data);
    if (!result) throw new Error(this.lineMsg() + "invalid line; this line should have an URL: " + data);
    var href = result[1].replace(/\]/g, "%5D");
    var anchorText = trim(result[2]) || href;
    this.printLine("[url="+href+"]"+anchorText+"[/url]");
    this.prevLine = "text";
    this.screenEmpty = false;
};

// *link_button
// Display button that takes you to an URL
Scene.prototype.link_button = function linkButton(data) {
    if (typeof window == "undefined") return;
    var result = /^(\S+)\s*(.*)/.exec(data);
    if (!result) throw new Error(this.lineMsg() + "invalid line; this line should have an URL: " + data);
    var href = result[1];
    var anchorText = trim(result[2]) || href;
    this.paragraph();
    var target = this.target;
    if (!target) target = document.getElementById('text');
    printButton(anchorText, target, false, function() {
      window.location.href = href;
    });
    this.prevLine = "empty";
    this.screenEmpty = false;
};

// how many spaces is this line indented?
Scene.prototype.getIndent = function getIndent(line) {
    if (line === null || line === undefined) return 0;
    var spaces = line.match(/^(\s*)/);
    if (spaces === null || spaces === undefined) return 0;
    if (/[^ \t]/.test(spaces[1])) {
      throw new Error(this.lineMsg() + "The indentation on this line includes whitespace that is neither a space nor a tab. Delete the whitespace and replace it with spaces or tabs.");
    }
    var whitespace = spaces[0];
    var len = whitespace.length;
    if (0 === len) return 0;
    var tab = /\t/.test(whitespace);
    var space = / /.test(whitespace);
    if (tab && space) {
        throw new Error(this.lineMsg()+"Tabs and spaces appear on the same line");
    }
    if (tab) {
        this.firstTab = this.lineNum+1;
        if (this.firstSpace) {
            throw new Error(this.lineMsg()+"Illegal mixing of spaces and tabs; this line has a tab, but there were spaces on line " + this.firstSpace);
        }
    } else {
        this.firstSpace = this.lineNum + 1;
        if (this.firstTab) {
            throw new Error(this.lineMsg()+"Illegal mixing of spaces and tabs; this line has a space, but there were tabs on line " + this.firstTab);
        }
    }
    return len;
};

// *comment ignorable text
Scene.prototype.comment = function comment(line) {
    if (this.debugMode) println("*comment " + line);
};

Scene.prototype.advertisement = function advertisement(durationInSeconds) {
  if (/^\s*\*delay_break/.test(this.lines[this.lineNum - 1])) {
    throw new Error(this.lineMsg() + "*advertisement is not allowed immediately after *delay_break (*delay_break includes its own advertisement)");
  }
  if (this.getVar("choice_prerelease") || this.getVar("choice_is_steam")) return;
  var self = this;
  this.finished = true;
  this.skipFooter = true;
  startLoading();
  checkPurchase("adfree", function(ok, result) {
    doneLoading();
    if (result.adfree) {
      self.finished = false;
      self.skipFooter = false;
      self.execute();
      return;
    }
    self.printLine("Come back later to play the next part of [i]${choice_title}[/i]!");
    self.paragraph();
    self.printLine("Or you can buy the game now to skip the wait.");
    self.paragraph();
    self.buyButton("adfree", "$1.99", null, "It");
    self.delay_break(durationInSeconds || 300);
  });
};

// *looplimit 5
// The number of times a given line is allowed to be accessed
Scene.prototype.looplimit = function looplimit(count) {
  this.temps._looplimit = num(count, this.lineNum, this.name);
};

Scene.prototype.hide_reuse = function hide_reuse() {
  this.temps.choice_reuse = "hide";
};

Scene.prototype.disable_reuse = function disable_reuse() {
  this.temps.choice_reuse = "disable";
};

Scene.prototype.allow_reuse = function allow_reuse() {
  this.temps.choice_reuse = "allow";
};

// *label labelName
// Labels a line for use later in *goto
// Do nothing here; these labels are parsed in this.parseLabel
Scene.prototype.label = function label() {};

// *print expr
// print the value of the specified expression
Scene.prototype.print = function scene_print(expr) {
    var value = this.evaluateExpr(this.tokenizeExpr(expr));
    this.prevLine = "text";
    this.screenEmpty = false;
    this.printLine(value);
};

Scene.prototype.parseInputText = function parseInputText(line) {
  var stack = this.tokenizeExpr(line);
  var variable = this.evaluateReference(stack);
  if ("undefined" === typeof this.temps[variable] && "undefined" === typeof this.stats[variable]) {
    throw new Error(this.lineMsg() + "Non-existent variable '" + variable + "'");
  }

  var inputOptions = { long: false, allow_blank: false };
  for (var i = 0; i < stack.length; i++) {
    var token = stack[i];
    if (token.name !== "VAR") {
      throw new Error(this.lineMsg() + "Couldn't understand this input_text line");
    }
    var option = token.value;
    if (typeof inputOptions[option] === "undefined") {
      throw new Error(this.lineMsg() + "Couldn't understand this input_text option: " + option);
    }
    inputOptions[option] = true;
  }
  return {variable:variable, inputOptions:inputOptions};
}

// *input_text var
// record text typed by the user and store it in the specified variable
Scene.prototype.input_text = function input_text(line) {
    var result = this.parseInputText(line);
    var variable = result.variable;

    this.finished = true;
    this.paragraph();
    var self = this;
    printInput(this.target, result.inputOptions, function(value) {
      safeCall(self, function() {
        value = trim(String(value));
        value = value.replace(/\n/g, "[n/]");
        if (self.nav) self.nav.bugLog.push("*input_text " + variable + " " + value);
        self.finished = false;
        self.setVar(variable, value);
        self.resetPage();
      });
    });
    if (this.debugMode) println(computeCookie(this.stats, this.temps, this.lineNum, this.indent));
};

// *input_number var min max
// record number typed by the user and store it in the specified variable
Scene.prototype.input_number = function input_number(data) {
    var stack = this.tokenizeExpr(data);
    if (!stack.length) throw new Error(this.lineMsg() + "Invalid input_number statement, expected three args: varname min max");
    var variable = this.evaluateReference(stack);
    if ("undefined" === typeof this.temps[variable] && "undefined" === typeof this.stats[variable]) {
      throw new Error(this.lineMsg() + "Non-existent variable '"+variable+"'");
    }

    var simpleConstants;
    if (stack.length == 2 && stack[0].name == "NUMBER" && stack[1].name == "NUMBER") {
      simpleConstants = true;
    } else {
      simpleConstants = false;
    }

    if (!stack.length) throw new Error(this.lineMsg() + "Invalid input_number statement, expected three args: varname min max");
    var minimum = this.evaluateValueToken(stack.shift(), stack);
    if (isNaN(minimum*1)) throw new Error(this.lineMsg() + "Invalid minimum, not numeric: " + minimum);
    if (!stack.length) throw new Error(this.lineMsg() + "Invalid input_number statement, expected three args: varname min max");

    var maximum = this.evaluateValueToken(stack.shift(), stack);
    if (stack.length) throw new Error(this.lineMsg() + "Invalid input_number statement, expected three args: varname min max");
    if (isNaN(maximum*1)) throw new Error(this.lineMsg() + "Invalid maximum, not numeric: " + maximum);

    // in quicktest, min and max can get mixed up as the interpreter "cheats" on if statements
    // so quicktest will ignore min/max errors unless they're simple constants e.g. *input_number x 6 4
    var checkMinMax = !this.quicktest || simpleConstants;
    if (checkMinMax && parseFloat(minimum) > parseFloat(maximum)) {
      throw new Error(this.lineMsg() + "Minimum " + minimum+ " should not be greater than maximum " + maximum);
    }

    function isInt(x) {
       var y=parseInt(x,10);
       if (isNaN(y)) return false;
       return x==y && x.toString()==y.toString();
    }
    var intRequired;
    if (isInt(minimum) && isInt(maximum)) {
      intRequired = 1;
    }
    this.finished = true;
    this.paragraph();
    var self = this;
    printInput(this.target, {numeric: true}, function(value) {
      safeCall(self, function() {
        var numValue = parseFloat(""+value);
        if (isNaN(numValue)) {
          asyncAlert("Please type in a number.");
          return;
        }
        if (intRequired && !isInt(value)) {
          asyncAlert("Please type in an integer number.");
          return;
        }
        if (numValue < minimum * 1) {
          asyncAlert("Please use a number greater than or equal to " + minimum);
          return;
        }
        if (numValue > maximum * 1 && !this.quicktest) {
          asyncAlert("Please use a number less than or equal to " + maximum);
          return;
        }
        if (self.nav) self.nav.bugLog.push("*input_number " + variable + " " + value);
        self.finished = false;
        self.setVar(variable, numValue);
        self.resetPage();
      });
    }, minimum, maximum, intRequired);
    if (this.debugMode) println(computeCookie(this.stats, this.temps, this.lineNum, this.indent));
};

// *script code
// evaluate the specified ECMAScript
Scene.prototype.script = function script(code) {
    var stats = this.stats;
    var temps = this.temps;
    try {
      if (typeof window == "undefined") {
        (function() {
          var window = _global;
          eval(code);
        }).call(this);
      } else {
        eval(code);
      }
    } catch (e) {
      throw new Error(this.lineMsg() + "error executing *script: " + e + (e.stack ? "\n" + e.stack : ""));
    }
};

// is this a valid variable name?
Scene.prototype.validateVariable = function validateVariable(variable) {
    if (!variable || !/^[a-zA-Z]/.test(variable)) {
        throw new Error(this.lineMsg()+"Invalid variable name, must start with a letter: " + variable);
    }
    if (!/^\w+$/.test(variable)) {
        throw new Error(this.lineMsg()+"Invalid variable name: '" + variable + "'");
    }
    if (/^(and|or|true|false|scene|scenename)$/i.test(variable)) throw new Error(this.lineMsg()+"Invalid variable name, '" + variable + "' is a reserved word");
    if (/^choice_/.test(variable)) throw new Error(this.lineMsg()+"Invalid variable name, variables may not start with 'choice_'; this is a reserved prefix");
};

// *rand varname min max
// Set varname to a random number from min to max
//
// Example:
// *rand foo 1 6
//   roll a cube die
// *rand foo 1.0 6.0
//   compute a decimal from [1.0,6.0)
Scene.prototype.rand = function rand(data) {
    var stack = this.tokenizeExpr(data);
    if (!stack.length) throw new Error(this.lineMsg() + "Invalid rand statement, expected three args: varname min max");
    var variable = this.evaluateReference(stack);
    if ("undefined" === typeof this.temps[variable] && "undefined" === typeof this.stats[variable]) {
      throw new Error(this.lineMsg() + "Non-existent variable '"+variable+"'");
    }

    if (!stack.length) throw new Error(this.lineMsg() + "Invalid rand statement, expected three args: varname min max");
    var minimum = this.evaluateValueToken(stack.shift(), stack);
    if (!stack.length) throw new Error(this.lineMsg() + "Invalid rand statement, expected three args: varname min max");
    var maximum = this.evaluateValueToken(stack.shift(), stack);
    if (stack.length) throw new Error(this.lineMsg() + "Invalid rand statement, expected three args: varname min max");
    var diff;

    diff = maximum - minimum;
    if (isNaN(diff)) {
        throw new Error(this.lineMsg() + "Invalid rand statement, min and max must be numbers");
    }
    if (diff < 0) {
        throw new Error(this.lineMsg() + "Invalid rand statement, min must be less than max: " + minimum + " > " + maximum);
    }
    if (diff === 0) {
      this.setVar(variable, minimum);
      return;
    }
    function isInt(x) {
       var y=parseInt(x,10);
       if (isNaN(y)) return false;
       return x==y && x.toString()==y.toString();
    }
    var result;
    var random = Math.random();
    if (isInt(minimum) && isInt(maximum)) {
        // int random
        result = 1*minimum + Math.floor(random*(diff+1));
    } else {
        result = 1*minimum + random*diff;
    }
    this.setVar(variable, result);
    if (this.randomLog) {
      this.randomLog("*rand " + variable + " " + result);
    }
    if (this.nav) this.nav.bugLog.push("*rand " + variable + " " + result);
};

// *set varname expr
// sets the specified varname to the value of the expr
//
// Examples:
//
// literal
//     literal int: 2
//     literal decimal: 2.3
//     boolean value: true
//     quoted string: "fie"
//         with backslash escaping "she said it was \"ironic\"!"  "c:\\foo"
//     variable name: foo
//
// math
//     +: 2+2
//     -: foo-3
//     *: 2*3
//     /: 8/2
//     if one operand is a string, we'll try to parse it as a number, fail if that doesn't work
//
// fairmath
//     %+: foo%+30
//     %-: foo%-20
//
// concatenate
//     &: "foo"&bar
//
// may omit leading operand
//     *set foo +2
//     *set foo %+30
//     *set foo &"blah blah"
//
// spaces optional
//     *set foo+2
//     *set foo bar + 2
//     *set bar% +30
//
// multiple operators in one line, parentheses mandatory
//     *set foo (foo+2)/4
//     *set foo 2+(foo/2)
//     *set foo (foo/2)+(bar/3)
//     *set foo +(bar/3)
//
// set variable by reference
//
//     *set foo bar
//     *set {foo} 3
//     *comment now bar=3
Scene.prototype.set = function set(line) {
    var stack = this.tokenizeExpr(line);
    var variable = this.evaluateReference(stack);
    if ("undefined" === typeof this.temps[variable] && "undefined" === typeof this.stats[variable]) {
      throw new Error(this.lineMsg() + "Non-existent variable '"+variable+"'");
    }
    if (stack.length === 0) throw new Error(this.lineMsg()+"Invalid set instruction, no expression specified: " + line);
    // if the first token is an operator, then it's implicitly based on the variable
    if (/OPERATOR|FAIRMATH/.test(stack[0].name)) stack.unshift({name:"VAR", value:variable, pos:"(implicit)"});
    var value = this.evaluateExpr(stack);
    this.setVar(variable, value);
};

// *setref variableExpr expr
// just like *set, but variableExpr is a string expression naming a variable reference
//
// Example:
// *set foo "bar"
// *setref foo 3
// *comment now bar=3
Scene.prototype.setref = function setref(line) {
    var stack = this.tokenizeExpr(line);
    var variable = this.evaluateValueToken(stack.shift(), stack);
    variable = String(variable).toLowerCase();

    if ("undefined" === typeof this.temps[variable] && "undefined" === typeof this.stats[variable]) {
      throw new Error(this.lineMsg() + "Non-existent variable '"+variable+"'");
    }

    // if the first token is an operator, then it's implicitly based on the variable
    if (/OPERATOR|FAIRMATH/.test(stack[0].name)) stack.unshift({name:"VAR", value:variable, pos:"(implicit)"});
    var value = this.evaluateExpr(stack);
    this.setVar(variable, value);
};

Scene.prototype.share_this_game = function share_links(now) {
  now = !!trim(now);
  this.paragraph();
  printShareLinks(this.target, now);
  this.prevLine = "empty"; // printShareLinks provides its own paragraph break
};

Scene.prototype.more_games = function more_games(now) {
  if (typeof window == "undefined" || typeof moreGames == "undefined") return;
  if (!!trim(now)) {
    moreGames();
    return;
  }
  var self = this;
  this.paragraph();
  var target = this.target;
  if (!target) target = document.getElementById('text');
  var button = printButton("Play More Games Like This", target, false,
    function() {
      safeCall(self, moreGames);
    }
  );

  setClass(button, "");
  this.prevLine = "block";
};

Scene.prototype.ending = function ending() {
    if (typeof window == "undefined") return;
    this.paragraph();
    var groups = [""];
    options = [];
    options.push({name:"Play again.", group:"choice", restart:true});
    if (!window.isOmnibusApp) options.push({name:"Play more games like this.", group:"choice", moreGames:true});
    options.push({name:"Share this game with friends.", group:"choice", share:true});
    options.push({name:"Email me when new games are available.", group:"choice", subscribe:true});

    var self = this;
    function endingMenu() {
      printFollowButtons();
      self.renderOptions([""], options, function(option) {
        if (option.restart) {
          return restartGame();
        } else if (option.moreGames) {
          self.more_games("now");
          if (typeof curl != "undefined") curl();
        } else if (option.share) {
          clearScreen(function() {
            self.share_this_game("now");
            endingMenu();
          });
        } else if (option.subscribe) {
          subscribeLink();
        }
      });
    }
    endingMenu();
    this.finished = true;
};

Scene.prototype.restart = function restart() {
  delayBreakEnd();
  if (this.secondaryMode) {
    if (this.secondaryMode == "stats") {
      this.reset();
      this.redirect_scene(this.nav.getStartupScene());
    } else {
      throw new Error(this.lineMsg() + "Cannot *restart in " + this.secondaryMode + " mode");
    }
  } else {
    restartGame();
  }
  this.finished = true;
};

/* Subscribe options, in JSON format.
"now" on mobile means we should immediately mailto: the subscribe address
otherwise, we should display a Subscribe button which launches the mailto:
On non-mailte: platforms, we ignore "now"

"allowContinue" is the default; setting it to false blocks the "No, Thanks"
button and the "Next" button after successfully subscribing
Only Coming Soon pages use "allowContinue"

"message" is the message we'll show to justify subscribing
the default message is: "we'll notify you when our next game is ready!" */
Scene.prototype.subscribe = function scene_subscribe(data) {
  this.paragraph();
  var options = {};
  if (data) {
    try {
      options = JSON.parse(data);
    } catch (e) {
      throw new Error(this.lineMsg() + "Couldn't parse subscribe arguments: " + data);
    }
  }
  this.prevLine = "block";
  this.finished = true;
  this.skipFooter = true;
  var self = this;
  subscribe(this.target, options, function(now) {
    self.finished = false;
    // if "now" actually worked, then continue the scene
    // otherwise, reset the page before continuing
    if (now) {
      self.skipFooter = false;
      self.execute();
    } else {
      self.resetPage();
    }
  });
};

Scene.prototype.restore_game = function restore_game(data) {
  var cancelLabel;
  if (data) {
    var result = /^cancel=(\S+)$/.exec(data);
    if (!result) throw new Error(this.lineMsg() + "invalid restore_game line: " + data);
    cancelLabel = result[1];
  }
  this.finished = true;
  this.skipFooter = true;
  var self = this;
  var daysToUndelete = 30;
  var unrestorableScenes = this.parseRestoreGame(false/*alreadyFinished*/);
  function renderRestoreMenu(saveList, dirtySaveList) {
    var recentlyDeletedCount = 0;
    self.paragraph();
    var options = [];
    for (var i = 0; i < saveList.length; i++) {
      var save = saveList[i];
      var date = new Date(save.timestamp*1);
      if (!save) continue;
      var name = "";
      if (save.temps && save.temps.choice_restore_name) name = save.temps.choice_restore_name;
      if (save.deleted) {
        if (!save.undeleted || save.undeleted < save.deleted) {
          if ((Date.now() - save.deleted) < daysToUndelete * 1000 * 60 * 60 * 24) {
            recentlyDeletedCount++;
          }
          continue;
        }
      }
      options.push({name:name + " ("+simpleDateTimeFormat(date)+")", group:"choice", state:save});
    }
    var anythingToDelete = options.length > 0;
    if (false) options.push({name:"Restore using a password.", group:"choice", password:true});
    options.push({name:"Retrieve saved games online from choiceofgames.com.", group:"choice", fetch:true});
    if (anythingToDelete) options.push({ name: "Delete saves.", group: "choice", deleting: true });
    if (recentlyDeletedCount) {
      var recentlyDeletedMessage;
      if (recentlyDeletedCount === 1) {
        recentlyDeletedMessage = "1 save recently deleted."
      } else {
        recentlyDeletedMessage = recentlyDeletedCount + " saves recently deleted."
      }
      options.push({ name: "Undelete saves. (" + recentlyDeletedMessage + ")", group: "choice", undeleting: true });
    }
    if (dirtySaveList.length) options.push({name:"Upload saved games to choiceofgames.com.", group:"choice", upload:true});
    options.push({name:"Cancel.", group:"choice", cancel:true});
    var groups = [""];
    self.renderOptions(groups, options, function(option) {
      function synchronizeBeforeEdit(renderMenu) {
        clearScreen(function () {
          fetchEmail(function (defaultEmail) {
            self.printLine("Please type your email address to identify yourself.");
            self.paragraph();
            promptEmailAddress(this.target, defaultEmail, "allowContinue", function (cancel, email) {
              if (cancel) {
                self.finished = false;
                if (typeof cancelLabel !== "undefined") {
                  self["goto"](cancelLabel);
                }
                self.resetPage();
                return;
              }
              clearScreen(function () {
                startLoading();
                getRemoteSaves(email, function (remoteSaveList) {
                  doneLoading();
                  self.prevLine = "text";
                  if (!remoteSaveList) {
                    self.printLine("Error downloading saves. Please try again later.");
                    renderRestoreMenu(saveList, dirtySaveList);
                  } else {
                    mergeRemoteSaves(remoteSaveList, "recordDirty", function (saveList, newRemoteSaves, dirtySaveList) {
                      if (dirtySaveList && dirtySaveList.length) {
                        submitDirtySaves(dirtySaveList, email, function (ok) {
                          if (ok) {
                            renderMenu(email);
                          } else {
                            self.printLine("Error downloading saves. Please try again later.");
                            renderRestoreMenu(saveList, dirtySaveList);
                          }
                        });
                      } else {
                        renderMenu(email);
                      }
                    });
                  }
                });
              });
            });
          });
        });
      }


      if (option.upload) {
        clearScreen(function() {
          fetchEmail(function(defaultEmail){
            self.printLine("Please type your email address to identify yourself.");
            self.paragraph();
            promptEmailAddress(this.target, defaultEmail, "allowContinue", function(cancel, email) {
              if (cancel) {
                self.finished = false;
                if (typeof cancelLabel !== "undefined") {
                  self["goto"](cancelLabel);
                }
                self.resetPage();
                return;
              }
              clearScreen(function() {
                startLoading();
                submitDirtySaves(dirtySaveList, email, function(ok) {
                  doneLoading();
                  self.prevLine = "text"; // Put some space between the message and the option list
                  if (!ok) {
                    self.printLine("Error uploading saves. Please try again later.");
                    renderRestoreMenu(saveList, dirtySaveList);
                  } else {
                    var count = dirtySaveList.length + (dirtySaveList.length == 1 ? " save" : " saves");
                    self.printLine("Uploaded " + count + ".");
                    renderRestoreMenu(saveList, []);
                  }
                });
              });
            });
          });
        });
      } else if (option.fetch) {
        clearScreen(function() {
          fetchEmail(function(defaultEmail){
            self.printLine("Please type your email address to identify yourself.");
            self.paragraph();
            promptEmailAddress(this.target, defaultEmail, "allowContinue", function(cancel, email) {
              if (cancel) {
                self.finished = false;
                if (typeof cancelLabel !== "undefined") {
                  self["goto"](cancelLabel);
                }
                self.resetPage();
                return;
              }
              clearScreen(function() {
                startLoading();
                getRemoteSaves(email, function (remoteSaveList) {
                  doneLoading();
                  self.prevLine = "text";
                  if (!remoteSaveList) {
                    self.printLine("Error downloading saves. Please try again later.");
                    renderRestoreMenu(saveList, dirtySaveList);
                  } else {
                    mergeRemoteSaves(remoteSaveList, "recordDirty", function(saveList, newRemoteSaves, dirtySaveList) {
                      if (!remoteSaveList.length) {
                        self.printLine("No saves downloaded for email address \""+email+"\". (Is that the correct email address?) If you're having trouble, please contact support at "+getSupportEmail()+".");
                        renderRestoreMenu(saveList, dirtySaveList);
                      } else {
                        var downloadCount = remoteSaveList.length + " saved " + (remoteSaveList.length == 1 ? "game" : "games");
                        var newCount = newRemoteSaves + " new saved " + (newRemoteSaves == 1 ? "game" : "games");
                        self.printLine("Synchronized " + downloadCount + ". Downloaded " + newCount + ".");
                        renderRestoreMenu(saveList, dirtySaveList);
                      }
                    });
                  }
                });
              });
            });
          });
        });
      } else if (option.deleting) {
        synchronizeBeforeEdit(function renderDeleteMenu(email) {
          var options = [];
          for (var i = 0; i < saveList.length; i++) {
            var save = saveList[i];
            var date = new Date(save.timestamp * 1);
            if (!save) continue;
            if (save.deleted) {
              if (!save.undeleted || save.undeleted < save.deleted) {
                continue;
              }
            }
            var name = "";
            if (save.temps && save.temps.choice_restore_name) name = save.temps.choice_restore_name;
            options.push({ name: name + " (" + simpleDateTimeFormat(date) + ")", group: "choice", state: save });
          }
          if (!options.length) {
            self.printLine("There are no saves to delete.");
            renderRestoreMenu(saveList, dirtySaveList);
            return;
          }
          function submitButtonNameFunction(count) {
            if (count == 1) {
              return "Delete 1 Save";
            } else {
              return "Delete " + count + " Saves";
            }
          }
          printCheckboxes(options, submitButtonNameFunction, function (results) {
            startLoading();
            var deletionDate = Date.now();
            clearScreen(function () {
              Promise.all(results.map(function (result, i) {
                if (result) {
                  return new Promise(function (resolve, reject) {
                    var save = options[i].state;
                    var slot = "save" + save.timestamp;
                    var oldDeleted = save.deleted;
                    save.deleted = deletionDate;
                    var value = computeCookie(save.stats, save.temps, save.lineNum, save.indent, save.deleted, save.undeleted);
                    writeCookie(value, slot, function(ok) {
                      submitRemoteSave(slot, email, !"subscribe", function (ok) {
                        if (ok) {
                          resolve();
                        } else {
                          save.deleted = oldDeleted;
                          var value = computeCookie(save.stats, save.temps, save.lineNum, save.indent, save.deleted, save.undeleted);
                          writeCookie(value, slot, function () {});
                          reject(new Error("Failed submitting " + slot));
                        }
                      })
                    })
                  });
                }
              })).then(function () {
                doneLoading();
                renderRestoreMenu(saveList, dirtySaveList);
              }).catch(function(err) {
                self.printLine("There was an error deleting saves. Please try again later.");
                renderRestoreMenu(saveList, dirtySaveList);
              });
            });
          });
          printFooter();
        });
      } else if (option.undeleting) {
        synchronizeBeforeEdit(function renderUndeleteMenu(email) {
          var options = [];
          for (var i = 0; i < saveList.length; i++) {
            var save = saveList[i];
            var date = new Date(save.timestamp * 1);
            if (!save) continue;
            if (!save.deleted || save.deleted < save.undeleted) {
              continue;
            }
            var name = "";
            if (save.temps && save.temps.choice_restore_name) name = save.temps.choice_restore_name;
            options.push({ name: name + " (" + simpleDateTimeFormat(date) + ")", group: "choice", state: save });
          }
          if (!options.length) {
            self.printLine("There are no recently deleted saves to undelete.");
            renderRestoreMenu(saveList, dirtySaveList);
            return;
          }
          function submitButtonNameFunction(count) {
            if (count == 1) {
              return "Undelete 1 Save";
            } else {
              return "Undelete " + count + " Saves";
            }
          }
          printCheckboxes(options, submitButtonNameFunction, function (results) {
            startLoading();
            var undeletionDate = Date.now();
            clearScreen(function () {
              Promise.all(results.map(function (result, i) {
                if (result) {
                  return new Promise(function (resolve) {
                    var save = options[i].state;
                    var slot = "save" + save.timestamp;
                    var oldUndeleted = save.undeleted;
                    save.undeleted = undeletionDate;
                    var value = computeCookie(save.stats, save.temps, save.lineNum, save.indent, save.deleted, save.undeleted);
                    writeCookie(value, slot, function (ok) {
                      submitRemoteSave(slot, email, !"subscribe", function (ok) {
                        if (ok) {
                          resolve();
                        } else {
                          save.undeleted = oldUndeleted;
                          var value = computeCookie(save.stats, save.temps, save.lineNum, save.indent, save.deleted, save.undeleted);
                          writeCookie(value, slot, function () { });
                          reject(new Error("Failed submitting " + slot));
                        }
                      })
                    })
                  });
                }
              })).then(function () {
                doneLoading();
                renderRestoreMenu(saveList, dirtySaveList);
              }).catch(function (err) {
                self.printLine("There was an error undeleting saves. Please try again later.");
                renderRestoreMenu(saveList, dirtySaveList);
              });
            });
          });
          printFooter();
        });
      } else if (option.password) {
        clearScreen(function() {
          self.restore_password();
        });
      } else {
        if (option.cancel) {
          self.finished = false;
          if (typeof cancelLabel !== "undefined") {
            self["goto"](cancelLabel);
          }
          self.resetPage();
        } else {
          var state = option.state;
          var sceneName = null;
          if (state.stats && state.stats.sceneName) sceneName = (""+state.stats.sceneName).toLowerCase();
          var unrestorable = unrestorableScenes[sceneName];

          if (unrestorable) {
            asyncAlert(unrestorable);
            self.finished = false;
            self.resetPage();
            return;
          }

          saveCookie(function() {
            clearScreen(function() {
              restoreGame(state, null, /*userRestored*/true);
            });
          }, "", state.stats, state.temps, state.lineNum, state.indent);
        }
      }
    });
  }
  getDirtySaveList(function(dirtySaveList) {
    getSaves(function(saveList) {
      renderRestoreMenu(saveList, dirtySaveList);
    });
  });
};

Scene.prototype.restore_password = function restore_password() {
  var alreadyFinished = this.finished;
  this.finished = true;
  this.paragraph();
  this.printLine('Please paste your password here, then press "Next" below to continue.');
  this.prevLine = "text";
  this.paragraph();
  var self = this;
  var unrestorableScenes = this.parseRestoreGame(alreadyFinished);
  getPassword(this.target, function (cancel, password) {
    if (cancel) {
      self.finished = false;
      self.resetPage();
      return;
    }
    password = password.replace(/\s/g, "");
    password = password.replace(/^.*BEGINPASSWORD-----/, "");
    var token = self.deobfuscatePassword(password);
    token = token.replace(/^[^\{]*/, "");
    token = token.replace(/[^\}]*$/, "");
    var state;
    try {
      state = jsonParse(token);
    } catch (e) {
      asyncAlert("Sorry, that password was invalid. Please contact " + getSupportEmail() + " for assistance. Be sure to include your password in the email.");
      return;
    }

    var sceneName = null;
    if (state.stats && state.stats.sceneName) sceneName = (""+state.stats.sceneName).toLowerCase();

    var unrestorable = unrestorableScenes[sceneName];
    if (unrestorable) {
      asyncAlert(unrestorable);
      self.finished = false;
      self.resetPage();
      return;
    }

    saveCookie(function() {
      clearScreen(function() {
        // we're going to pretend not to be user restored, so we get reprompted to save
        restoreGame(state, null, /*userRestored*/false);
      });
    }, "", state.stats, state.temps, state.lineNum, state.indent);
  });
  if (alreadyFinished) printFooter();
};

Scene.prototype.parseRestoreGame = function parseRestoreGame(alreadyFinished) {
    if (alreadyFinished) {
      // if we're already finished, the printLoop bumped us an extra line ahead
      this.lineNum--;
      this.rollbackLineCoverage();
    }
    // nextIndent: the level of indentation after the current line
    var nextIndent = null;
    var unrestorableScenes = {};
    var line;
    var startIndent = this.indent;
    while(isDefined(line = this.lines[++this.lineNum])) {
        if (!trim(line)) {
            this.rollbackLineCoverage();
            continue;
        }
        var indent = this.getIndent(line);
        if (nextIndent === null || nextIndent === undefined) {
            // initialize nextIndent with whatever indentation the line turns out to be
            // ...unless it's not indented at all
            if (indent > startIndent) {
                this.indent = nextIndent = indent;
            }
        }
        if (indent <= startIndent) {
            // it's over!
            if (!alreadyFinished) {
              this.lineNum--;
              this.rollbackLineCoverage();
            }
            return unrestorableScenes;
        }
        if (indent != this.indent) {
            // all chart rows are supposed to be at the same indentation level
            // anything at the wrong indentation level might be a mis-indented title/definition
            // or just a typo
            throw new Error(this.lineMsg() + "invalid indent, expected "+this.indent+", was " + indent);
        }

        //
        // *restore_game
        //   ending You can't restore to the ending.

        line = trim(line);
        var result = /^(\w+)\s+(.*)/.exec(line);
        if (!result) throw new Error(this.lineMsg() + "invalid line; this line should have a scene name followed by an error message: " + line);
        var sceneName = result[1].toLowerCase();
        var error = trim(result[2]);
        unrestorableScenes[sceneName] = error;
    }
    if (!alreadyFinished) {
      this.lineNum--;
      this.rollbackLineCoverage();
    }
    return unrestorableScenes;
};

Scene.prototype.check_registration = function scene_checkRegistration() {
  if (typeof window == "undefined" || typeof isRegistered == "undefined") return;
  this.finished = true;
  this.skipFooter = true;
  var self = this;
  isRegistered(function() {
    self.finished = false;
    self.skipFooter = false;
    self.execute();
  });
};

Scene.prototype.login = function scene_login(optional) {
  if (typeof window == "undefined" || typeof loginForm == "undefined") return;
  optional = trim(optional);
  if (optional) {
    if (optional != "optional") throw new Error(this.lineMsg() + "invalid *login option: " + optional);
    optional = 1;
  }
  var self = this;
  this.finished = true;
  this.skipFooter = true;
  this.paragraph();
  var target = this.target;
  if (!target) target = document.getElementById('text');
  loginForm(target, optional, null, function() {
    clearScreen(function() {
      self.finished = false;
      self.prevLine = "empty";
      self.screenEmpty = true;
      self.execute();
    });
  });
};

Scene.prototype.save_game = function save_game(destinationSceneName) {
  if (!destinationSceneName) throw new Error(this.lineMsg()+"*save_game requires a destination file name, e.g. *save_game Episode2");
  if (this.temps.choice_user_restored) return;
  var self = this;
  this.finished = true;
  this.skipFooter = true;
  fetchEmail(function(defaultEmail){
    self.paragraph();
    var form = document.createElement("form");
    setClass(form, "saveGame");

    form.action="#";

    var message = document.createElement("div");
    message.style.color = "red";
    message.style.fontWeight = "bold";
    form.appendChild(message);

    var saveName = document.createElement("input");
    saveName.type="text";
    saveName.name="saveName";
    saveName.setAttribute("placeholder", "Type a name for your saved game");
    saveName.setAttribute("style", "display:block; font-size: 25px; width: 90%; margin: 1rem 0");
    form.appendChild(saveName);

    var hideEmailForm = false;
    // hideEmailForm = _global.automaticCloudStorage;
    if (!hideEmailForm) {
      println("Please login to the choiceofgames.com save system with your email address below.", form);

      var emailInput = document.createElement("input");
      // This can fail on IE
      try { emailInput.type="email"; } catch (e) {}
      emailInput.name="email";
      emailInput.value=defaultEmail;
      emailInput.setAttribute("placeholder", "you@example.com");
      emailInput.setAttribute("style", "display:block; font-size: 25px; width: 90%; margin: 1rem 0");
      form.appendChild(emailInput);

      var subscribeLabel = document.createElement("label");
      subscribeLabel.setAttribute("for", "subscribeBox");
      var subscribeBox = document.createElement("input");
      subscribeBox.type = "checkbox";
      subscribeBox.name = "subscribe";
      subscribeBox.setAttribute("id", "subscribeBox");
      subscribeBox.setAttribute("checked", true);
      subscribeLabel.appendChild(subscribeBox);
      subscribeLabel.appendChild(document.createTextNode("Email me when new games are available."));
      form.appendChild(subscribeLabel);
    }

    var target = this.target;
    if (!target) target = document.getElementById('text');
    target.appendChild(form);
    printButton("Next", form, true);

    printButton("Cancel", target, false, function() {
      clearScreen(function() {
        self.finished = false;
        self.prevLine = "empty";
        self.screenEmpty = true;
        self.execute();
      });
    });

    form.onsubmit = function(e) {
      preventDefault(e);
      safeCall(this, function() {
        var messageText;
        if (!trim(saveName.value)) {
          messageText = document.createTextNode("Please type a name for your saved game.");
          message.innerHTML = "";
          message.appendChild(messageText);
          return;
        }

        var slot = "save" + new Date().getTime();
        // create a clone stats object whose scene name is the destination scene
        var saveStats = {};
        for (var stat in self.stats) {
          if ("scene" == stat) continue;
          saveStats[stat] = self.stats[stat];
        }
        saveStats.scene = {name:destinationSceneName};

        if (hideEmailForm) {
          clearScreen(function() {
            saveCookie(function() {
              recordSave(slot, function() {
                self.finished = false;
                self.prevLine = "empty";
                self.screenEmpty = true;
                self.execute();
              });
            }, slot, saveStats, {choice_reuse:"allow", choice_user_restored:true, choice_restore_name:saveName.value}, 0, 0);
          });
          return;
        }

        var shouldSubscribe = subscribeBox.checked;
        var subscribe = shouldSubscribe && (window.isHeartsChoice ? "hc" : "cog");
        var email = trim(emailInput.value);
        if (!/^\S+@\S+\.\S+$/.test(email)) {
          messageText = document.createTextNode("Sorry, \""+email+"\" is not an email address.  Please type your email address again.");
          message.innerHTML = "";
          message.appendChild(messageText);
          return;
        }

        recordEmail(email, function() {
          clearScreen(function() {
            saveCookie(function() {
              recordSave(slot, function() {
                startLoading();
                submitRemoteSave(slot, email, subscribe, function(ok) {
                  doneLoading();
                  if (!ok) {
                    asyncAlert("Couldn't upload your saved game to choiceofgames.com. You can try again later from the Restore menu.", function() {
                      self.finished = false;
                      self.prevLine = "empty";
                      self.screenEmpty = true;
                      self.execute();
                    });
                  } else {
                    self.finished = false;
                    self.prevLine = "empty";
                    self.screenEmpty = true;
                    self.execute();
                  }
                });
              });
            }, slot, saveStats, {choice_reuse:"allow", choice_user_restored:true, choice_restore_name:saveName.value}, 0, 0);
          });
        });
      });
    };

    printFooter();
  });
};

Scene.prototype.show_password = function show_password() {
  if (this.temps.choice_user_restored) return;
  this.paragraph();
  if (typeof(window) != "undefined" && !window.isMobile) {
    this.printLine('Please copy and paste the password in a safe place, then press "Next" below to continue.');
    println("", this.target);
    println("", this.target);
  }
  var password = computeCookie(this.stats, this.temps, this.lineNum, this.indent);
  password = this.obfuscate(password);
  showPassword(this.target, password);
  this.prevLine = "block";
};

Scene.prototype.obfuscate = function obfuscate(password) {
  var self = this;
  return password.replace(/./g,
    function(x) {
      var y = self.obfuscator[x];
      return y;
    }
  );
};

// The obfuscator must take US-ASCII and obfuscate it
// for use in a password.  This password will be sent via
// HTML email and its whitespace handling will be unpredictable,
// So we can't output any of these characters: [ <>&]
// Since we're losing four characters of output, we JSON-escape
// four characters of input [^`|~].
Scene.prototype.obfuscator = {
  " ": "k",
  "!": "E",
  "\"": "`",
  "#": "\\",
  "$": "r",
  "%": "J",
  "&": "o",
  "'": "0",
  "(": "Z",
  ")": "M",
  "*": "G",
  "+": "t",
  ",": "Y",
  "-": "f",
  ".": "2",
  "/": "!",
  "0": "i",
  "1": "*",
  "2": "1",
  "3": "3",
  "4": "[",
  "5": "6",
  "6": "v",
  "7": "\"",
  "8": "F",
  "9": "9",
  ":": "{",
  ";": "Q",
  "<": "?",
  "=": "5",
  ">": "#",
  "?": "K",
  "@": "/",
  "A": "=",
  "B": "N",
  "C": "z",
  "D": "$",
  "E": "W",
  "F": "(",
  "G": ")",
  "H": "q",
  "I": "C",
  "J": "+",
  "K": "U",
  "L": ".",
  "M": "H",
  "N": "B",
  "O": "S",
  "P": "X",
  "Q": "I",
  "R": "-",
  "S": "m",
  "T": "D",
  "U": "^",
  "V": "A",
  "W": "a",
  "X": "y",
  "Y": ",",
  "Z": "d",
  "[": "O",
  "\\": "s",
  "]": "8",
  "^": "sVii6h",
  "_": "]",
  "`": "sViivi",
  "a": "4",
  "b": "g",
  "c": "%",
  "d": "w",
  "e": "h",
  "f": "n",
  "g": "b",
  "h": "7",
  "i": "x",
  "j": "~",
  "k": "_",
  "l": "l",
  "m": ":",
  "n": "c",
  "o": "L",
  "p": "j",
  "q": "u",
  "r": "R",
  "s": "}",
  "t": "p",
  "u": "V",
  "v": "P",
  "w": "'",
  "x": "T",
  "y": "|",
  "z": "@",
  "{": "e",
  "|": "sVii\"%",
  "}": ";",
  "~": "sVii\"h"
};
Scene.prototype.deobfuscator = {
  "k": " ",
  "E": "!",
  "`": "\"",
  "\\": "#",
  "r": "$",
  "J": "%",
  "o": "&",
  "0": "'",
  "Z": "(",
  "M": ")",
  "G": "*",
  "t": "+",
  "Y": ",",
  "f": "-",
  "2": ".",
  "!": "/",
  "i": "0",
  "*": "1",
  "1": "2",
  "3": "3",
  "[": "4",
  "6": "5",
  "v": "6",
  "\"": "7",
  "F": "8",
  "9": "9",
  "{": ":",
  "Q": ";",
  "?": "<",
  "5": "=",
  "#": ">",
  "K": "?",
  "/": "@",
  "=": "A",
  "N": "B",
  "z": "C",
  "$": "D",
  "W": "E",
  "(": "F",
  ")": "G",
  "q": "H",
  "C": "I",
  "+": "J",
  "U": "K",
  ".": "L",
  "H": "M",
  "B": "N",
  "S": "O",
  "X": "P",
  "I": "Q",
  "-": "R",
  "m": "S",
  "D": "T",
  "^": "U",
  "A": "V",
  "a": "W",
  "y": "X",
  ",": "Y",
  "d": "Z",
  "O": "[",
  "s": "\\",
  "8": "]",
  "]": "_",
  "4": "a",
  "g": "b",
  "%": "c",
  "w": "d",
  "h": "e",
  "n": "f",
  "b": "g",
  "7": "h",
  "x": "i",
  "~": "j",
  "_": "k",
  "l": "l",
  ":": "m",
  "c": "n",
  "L": "o",
  "j": "p",
  "u": "q",
  "R": "r",
  "}": "s",
  "p": "t",
  "V": "u",
  "P": "v",
  "'": "w",
  "T": "x",
  "|": "y",
  "@": "z",
  "e": "{",
  ";": "}"
};

Scene.prototype.deobfuscatePassword = function deobfuscatePassword(password) {
  var self = this;
  password = password.replace(/./g,
    function(x) {
      var y = self.deobfuscator[x];
      return y;
    }
  );
  return password;
};

Scene.prototype.stat_chart = function stat_chart() {
  this.paragraph();
  var rows = this.parseStatChart();
  var target = this.target;
  if (!target) target = document.getElementById('text');

  var barWidth = 0;
  var standardFontSize = 0;

  function fixFontSize(span1, span2) {
    if (!standardFontSize) {
      if (window.getComputedStyle) {
        standardFontSize = parseInt(getComputedStyle(document.body).fontSize, 10);
      } else if (document.body.currentStyle) {
        standardFontSize = parseInt(document.body.currentStyle.fontSize, 10);
      } else {
        standardFontSize = 16;
      }
    }
    if (!barWidth) barWidth = span1.parentNode.offsetWidth;
    var spanMaxWidth, biggestSpanWidth;
    if (span2) {
      spanMaxWidth = barWidth / 2 - 1; /* minus one as a fudge factor; why is this needed? */
      biggestSpanWidth = Math.max(span1.offsetWidth, span2.offsetWidth);
    } else {
      spanMaxWidth = barWidth;
      biggestSpanWidth = span1.offsetWidth;
    }

    if (biggestSpanWidth > spanMaxWidth) {
      var newSize = Math.floor(standardFontSize * spanMaxWidth / biggestSpanWidth);
      span1.parentNode.style.fontSize = newSize + "px";
      if (window.getComputedStyle) {
        // on Android, if the user is using non-standand styles, browser may try to ignore our font setting
        var actual = parseInt(getComputedStyle(span1).fontSize, 10);
        if (actual > newSize) {
          newSize *= newSize / actual;
          span1.parentNode.style.fontSize = newSize + "px";
        }
      }
    }

  }

  for (i = 0; i < rows.length; i++) {
    var row = rows[i];
    var type = row.type;
    var variable = row.variable;
    var value = this.evaluateExpr(this.tokenizeExpr(variable));
    var label = this.replaceVariables(row.label);
    var definition = this.replaceVariables(row.definition || "");

    var statWidth, div, span, statValue;
    if (type == "text") {
      div = document.createElement("div");
      setClass(div, "statText");
      span = document.createElement("span");
      if (trim(label) || trim(value)) {
        printx(label + ": " + value, span);
      } else {
        // unofficial line_break
        printx(" ", span);
      }
      div.appendChild(span);
      target.appendChild(div);
    } else if (type == "percent") {
      div = document.createElement("div");
      setClass(div, "statBar statLine");
      span = document.createElement("span");
      printx("\u00a0\u00a0"+label+": "+value+"%", span);
      div.appendChild(span);
      statValue = document.createElement("div");
      setClass(statValue, "statValue");
      statValue.style.width = value+"%";
      statValue.innerHTML = "&nbsp;";
      div.appendChild(statValue);
      target.appendChild(div);
      fixFontSize(span);
    } else if (type == "opposed_pair") {
      div = document.createElement("div");
      setClass(div, "statBar statLine opposed");
      span0 = document.createElement("span");
      printx("\u00a0\u00a0"+label+": "+value+"% ", span0);
      div.appendChild(span0);
      span = document.createElement("span");
      span.setAttribute("style", "float: right");
      printx(this.replaceVariables(row.opposed_label)+": "+(100-value)+"%\u00a0\u00a0", span);
      div.appendChild(span);
      statValue = document.createElement("div");
      setClass(statValue, "statValue");
      statValue.style.width = value+"%";
      statValue.innerHTML = "&nbsp;";
      div.appendChild(statValue);
      target.appendChild(div);
      fixFontSize(span0, span);
    } else {
      throw new Error("Bug! Parser accepted an unknown row type: " + type);
    }
  }
  this.prevLine = "block";
  this.screenEmpty = false;
};

Scene.prototype.save_checkpoint = function saveCheckpoint(slot) {
  var stat;
  if (slot) {
    if (!/^[a-zA-Z0-9_]+$/.test(slot)) throw new Error(this.lineMsg() + "Invalid *save_checkpoint slot, must be only letters, numbers, and _ underscores: " + slot);
    slot = slot.toLowerCase();
    stat = "choice_saved_checkpoint_" + slot;
  } else {
    slot = "checkpoint";
    stat = "choice_saved_checkpoint";
  }
  if (this.secondaryMode) throw new Error(this.lineMsg() + "Cannot *save_checkpoint in " + this.secondaryMode + " mode");
  this.stats[stat] = true;
  this.temps.choice_just_restored_checkpoint = false;
  saveCookie(function () {}, slot, this.stats, this.temps, this.lineNum + 1, this.indent, this.debugMode, this.nav);
}

Scene.prototype.restore_checkpoint = function restoreCheckpoint(slot) {
  var stat;
  this.finished = true;
  this.skipFooter = true;
  var self = this;
  if (!this.testPath && this.secondaryMode) {
    if (this.secondaryMode === 'stats') {
      restoreCheckpointFromStats(function () {
        delete self.secondaryMode;
        delete self.saveSlot;
        self.restore_checkpoint(slot);
      })
      return;
    } else {
      throw new Error(this.lineMsg() + "Cannot *restore_checkpoint in " + this.secondaryMode + " mode");
    }
  }

  if (slot) {
    if (!/^[a-zA-Z0-9_]+$/.test(slot)) throw new Error(this.lineMsg() + "Invalid *restore_checkpoint slot, must be only letters, numbers, and _ underscores: " + slot);
    slot = slot.toLowerCase();
    stat = "choice_saved_checkpoint_" + slot;
  } else {
    slot = "checkpoint";
    stat = "choice_saved_checkpoint";
  }
  if (!this.stats[stat]) {
    var certainty = this.testPath ? " could fail; we might not have" : " failed; we haven't";
    if (slot === "checkpoint") {
      throw new Error(this.lineMsg() + "*restore_checkpoint" + certainty + " saved a checkpoint. Use *if " + stat);
    } else {
      throw new Error(this.lineMsg() + "*restore_checkpoint for slot " + slot + certainty + " reached *save_checkpoint " + slot + ". Use *if " + stat);
    }
  }

  if (this.testPath) return;

  var forcedStats = null;
  var forcedTemps = {choice_just_restored_checkpoint: true};
  if (this.stats.checkpoint_exclusions || this.temps.checkpoint_exclusions) {
    var exclusions = trim(this.getVar('checkpoint_exclusions').toLowerCase()).split(' ');
    for (var i = 0; i < exclusions.length; i++) {
      var exclusion = exclusions[i];
      if (exclusion in this.stats) {
        if (!forcedStats) forcedStats = {};
        forcedStats[exclusion] = this.stats[exclusion];
      }
      if (exclusion in this.temps) {
        if (!forcedTemps) forcedTemps = {};
        forcedTemps[exclusion] = this.temps[exclusion];
      }
    }
  }

  loadAndRestoreGame(slot, null, forcedStats, forcedTemps);
}

Scene.prototype.parseStatChart = function parseStatChart() {
    // nextIndent: the level of indentation after the current line
    var nextIndent = null;
    var rows = [];
    var line, line1, line2, line2indent;
    var startIndent = this.indent;
    while(isDefined(line = this.lines[++this.lineNum])) {
        if (!trim(line)) {
            this.rollbackLineCoverage();
            continue;
        }
        var indent = this.getIndent(line);
        if (nextIndent === null || nextIndent === undefined) {
            // initialize nextIndent with whatever indentation the line turns out to be
            // ...unless it's not indented at all
            if (indent <= startIndent) {
                throw new Error(this.lineMsg() + "invalid indent, expected at least one row");
            }
            this.indent = nextIndent = indent;
        }
        if (indent <= startIndent) {
            // it's over!
            this.rollbackLineCoverage();
            this.lineNum = this.previousNonBlankLineNum();
            this.rollbackLineCoverage();
            return rows;
        }
        if (indent != this.indent) {
            // all chart rows are supposed to be at the same indentation level
            // anything at the wrong indentation level might be a mis-indented title/definition
            // or just a typo
            throw new Error(this.lineMsg() + "invalid indent, expected "+this.indent+", was " + indent);
        }

        //
        // *stat_chart
        //   text wounds Wounds
        //     Definition
        //   percent Infamy Infamy
        //     Definition
        //   opposed_pair brutality
        //     Brutality
        //       Strength and cruelty
        //     Finesse
        //       Precision and aerial maneuverability
        //

        // TODO opposed_pair
        // TODO definitions
        // TODO variable substitutions
        // TODO *if/*else
        // TODO *line_break
        line = trim(line);
        var result = /^(text|percent|opposed_pair)\s+(.*)/.exec(line);
        if (!result) throw new Error(this.lineMsg() + "invalid line; this line should start with 'percent', 'text', or 'opposed_pair'");
        var type = result[1].toLowerCase();
        var data = trim(result[2]);
        if ("opposed_pair" == type) {
          this.getVar(data);
          line1 = this.lines[++this.lineNum];
          this.replaceVariables(line1);
          line1indent = this.getIndent(line1);
          if (line1indent <= this.indent) throw new Error(this.lineMsg() + "invalid indent; expected at least one indented line to indicate opposed pair name. indent: " + line1indent + ", expected greater than " + this.indent);
          line2 = this.lines[this.lineNum + 1];
          line2indent = this.getIndent(line2);
          if (line2indent <= this.indent) {
            // line1 was the only line
            rows.push({type: type, variable: data, label: data, opposed_label: trim(line1)});
          } else {
            this.lineNum++;
            this.replaceVariables(line2);
            if (line2indent == line1indent) {
              // two lines: first label, second label
              rows.push({type: type, variable: data, label: trim(line1), opposed_label: trim(line2)});
            } else if (line2indent > line1indent) {
              // line 2 is a definition; therefore the opposed_label and its definition must be on lines 3 and 4
              var line1definition = line2;
              var line3 = this.lines[++this.lineNum];
              this.replaceVariables(line3);
              var line3indent = this.getIndent(line3);
              if (line3indent != line1indent) throw new Error(this.lineMsg() + "invalid indent; this line should be the opposing label name. expected " + line1indent + " was " + line3indent);
              var line4 = this.lines[++this.lineNum];
              this.replaceVariables(line4);
              var line4indent = this.getIndent(line4);
              if (line4indent != line2indent) throw new Error(this.lineMsg() + "invalid indent; this line should be the opposing label definition. expected " + line2indent + " was " + line4indent);
              rows.push({type: type, variable: data, label: trim(line1), definition:trim(line2), opposed_label: trim(line3), opposed_definition: trim(line4)});
            } else {
              throw new Error(this.lineMsg() + "invalid indent; expected a second line with indent " + line1indent + " to match line " + this.lineNum + ", or else no more opposed_pair lines");
            }
          }
        } else {
          var variable, label;
          if (!/ /.test(data)) {
            variable = data;
            label = data;
          } else if (/^\(/.test(data)) {
            var parens = 0;
            var closingParen = -1;
            for (var i = 1; i < data.length; i++) {
              var c = data.charAt(i);
              if (c === "(") {
                parens++;
              } else if (c === ")") {
                if (parens) {
                  parens--;
                } else {
                  closingParen = i;
                  break;
                }
              }
            }
            if (closingParen == -1) {
              throw new Error(this.lineMsg() + "missing closing parenthesis");
            }
            variable = data.substring(1, closingParen);
            label = trim(data.substring(closingParen+1))
            if (label === "") {
              label = variable;
            }
          } else {
            result = /^(\S+) (.*)/.exec(data);
            if (!result) throw new Error(this.lineMsg() + "Bug! can't find a space when a space was found");
            variable = result[1];
            label = result[2];
          }
          this.evaluateExpr(this.tokenizeExpr(variable));
          this.replaceVariables(label);
          line2 = this.lines[this.lineNum + 1];
          line2indent = this.getIndent(line2);
          if (line2indent <= this.indent) {
            // No definition line
            rows.push({type: type, variable: variable, label: label});
          } else {
            this.lineNum++;
            this.replaceVariables(line2);
            rows.push({type: type, variable: variable, label: label, definition: trim(line2)});
          }
        }
    }
    return rows;
};

// *timer Dec 25, 2016 9:30:00 PDT
Scene.prototype.timer = function(dateString) {
  var end;
  if (dateString == "release") {
    if (typeof window === "undefined" || !window.releaseDate) return;
    end = window.releaseDate/1000;
  } else {
    end = Date.parse(dateString)/1000;
  }
  var now = new Date()/1000;
  if (now < end) {
    var target = this.target;
    if (!target) {
      target = document.createElement("p");
      document.getElementById('text').appendChild(target);
    }
    var self = this;
    showTicker(target, end, function() {
      clearScreen(loadAndRestoreGame());
    });
  }
}

// *delay_break 1200
Scene.prototype.delay_break = function(durationInSeconds) {
  if (isNaN(durationInSeconds * 1)) throw new Error(this.lineMsg() + "invalid duration");
  this.finished = true;
  this.skipFooter = true;
  var target = this.target;
  if (!target) {
    target = document.createElement("p");
    document.getElementById('text').appendChild(target);
  }
  this.paragraph();
  var self = this;
  delayBreakStart(function(delayStart) {
    window.blockRestart = true;
    var endTimeInSeconds = durationInSeconds * 1 + delayStart * 1;
    showTicker(target, endTimeInSeconds, function() {
      self.page_break_advertisement();
    });
    printFooter();
  });
};

// *delay_ending 1200 $2.99 $0.99
Scene.prototype.delay_ending = function(data) {
  // Steam doesn't do delay breaks and especially not skiponce
  if (typeof window != "undefined" && !!window.isSteamApp) {
    return this.ending();
  }
  var args = data.split(/ /);
  var durationInSeconds = args[0];
  var fullPriceGuess = args[1];
  var singleUsePriceGuess = args[2];
  if (isNaN(durationInSeconds * 1)) throw new Error(this.lineMsg() + "invalid duration");
  if (!/^\$/.test(fullPriceGuess)) throw new Error(this.lineMsg() + "invalid fullPriceGuess: \""+fullPriceGuess+"\"");
  if (singleUsePriceGuess && !/^\$/.test(singleUsePriceGuess)) throw new Error(this.lineMsg() + "invalid singleUsePriceGuess: \""+singleUsePriceGuess+"\"");
  this.finished = true;
  this.skipFooter = true;
  var self = this;
  checkPurchase("adfree", function(ok, result) {
    if (result.adfree || !result.billingSupported) {
      self.ending();
      return;
    }
    getPrice("adfree", function (fullPrice) {
      if (fullPrice == "guess") fullPrice = fullPriceGuess;
      getPrice("skiponce", function (singleUsePrice) {
        if (singleUsePrice == "guess") singleUsePrice = singleUsePriceGuess;

        options = [];
        var finishedWaiting = {name: "Play again after a short wait. ", unselectable: true};
        options.push(finishedWaiting);
        var upgradeSkip = {name: "Upgrade to the unlimited version for " + fullPrice + " to skip the wait forever."};
        options.push(upgradeSkip);
        var skipOnce = {name: "Skip the wait one time for " + singleUsePrice + "."};
        if (singleUsePriceGuess) options.push(skipOnce);
        var restorePurchasesOption = {name: "Restore purchases from another device."};
        if (isRestorePurchasesSupported()) options.push(restorePurchasesOption);
        var playMoreGames = {name: "Play more games like this."};
        options.push(playMoreGames);
        var emailMe = {name: "Email me when new games are available."};
        options.push(emailMe);

        function restartNow() {
          window.blockRestart = false;
          restartGame();
        }

        self.paragraph();
        printOptions([""], options, function(option) {
          if (option == playMoreGames) {
            self.more_games("now");
            if (typeof curl != "undefined") curl();
          } else if (option == emailMe) {
            subscribeLink();
          } else if (option == upgradeSkip) {
            purchase("adfree", restartNow);
          } else if (option == skipOnce) {
            purchase("skiponce", restartNow);
          } else if (option == restorePurchasesOption) {
            restorePurchases("adfree", function() {
              clearScreen(loadAndRestoreGame);
            });
          } else {
            return restartGame();
          }
        });

        var target = document.querySelector(".choice label");

        delayBreakStart(function(delayStart) {
          window.blockRestart = true;
          var endTimeInSeconds = durationInSeconds * 1 + delayStart * 1;
          showTicker(target, endTimeInSeconds, function() {
            clearScreen(function() {
              self.ending();
            });
          });
          printFooter();
        });
      });
    });
  });

};

// *if booleanExpr
// execute different code depending on whether the booleanExpr is true or false
//
// Examples:
// *if bool-expression
//     blah
//     blah
// *elseif bool-expression
//     blah
//     blah
// *else
//     blah
//     blah
// bool-expression
//     by reference
//         *set foo true
//         *if foo ...
//     equality
//         foo=2
//         foo="blah"
//         2="2"
//             true
//     inequality
//         foo>2
//         foo<2
//         foo<=2
//         foo>=2
//     and/or logic, parentheses mandatory
//         (foo=2) or (foo=3)
//         ((foo>4) and (foo<8)) or (bar=0)
//
// NOTE: *if commands may be used inside *choices, to make some choices conditionally available
Scene.prototype["if"] = function scene_if(line) {
    var stack = this.tokenizeExpr(line);
    var result = this.evaluateExpr(stack);
    if (this.debugMode) println(line + " :: " + result);
    result = bool(result, this.lineNum+1);
    if (result) {
        // "true" branch, just go on to the next line
        this.indent = this.getIndent(this.nextNonBlankLine());
    } else {
        // "false" branch; skip over the true branch
        this.skipTrueBranch(false);
    }
};

// TODO Rename this function to just skipBranch
Scene.prototype.skipTrueBranch = function skipTrueBranch(inElse) {
  var startIndent = this.indent;
  var nextIndent = null;
  var line;
  while (isDefined(line = this.lines[++this.lineNum])) {
      this.rollbackLineCoverage();
      if (!trim(line)) continue;
      var indent = this.getIndent(line);
      if (nextIndent === null || nextIndent === undefined) {
          if (indent <= startIndent) throw new Error(this.lineMsg() + "invalid indent, expected at least one line in 'if' true block");
          nextIndent = indent;
      }
      if (indent <= startIndent) {
          // true block is over
          var parsed = null;
          // check to see if this is an *else or *elseif
          if (indent == startIndent) parsed = /^\s*\*(\w+)(.*)/.exec(line);
          if (!parsed || inElse) {
              this.lineNum = this.previousNonBlankLineNum();
              this.rollbackLineCoverage();
              this.indent = indent;
              return;
          }
          var command = parsed[1].toLowerCase();
          var data = trim(parsed[2]);
          if ("else" == command) {
              if (data) {
                if (/^if\b/.test(data)) {
                  throw new Error(this.lineMsg() + "'else if' is invalid, use 'elseif'");
                }
                throw new Error(this.lineMsg() + "nothing should appear on a line after 'else': " + data);
              }
              this.lineNum = this.lineNum; // code coverage
              // go on to the next line
              this.indent = this.getIndent(this.nextNonBlankLine());
          } else if (/^else?if$/.test(command)) {
              this.lineNum = this.lineNum; // code coverage
              this["if"](data);
          } else if ("comment" == command) {
              continue;
          } else {
              this.lineNum = this.previousNonBlankLineNum();
              this.rollbackLineCoverage();
              this.indent = this.getIndent(this.nextNonBlankLine());
          }
          return;
      }
      if (indent < nextIndent) {
          // *if foo
          //      foo
          //    bar
          throw new Error(this.lineMsg() + "invalid indent, expected "+nextIndent+", was " + indent);
      }
  }
};

Scene.prototype["else"] = Scene.prototype.elsif = Scene.prototype.elseif = function scene_else(data, inChoice) {
    // Authors can avoid using goto to get out of an if branch with:  *create implicit_control_flow true
    // This avoids the error message at the end of the function.
    if (inChoice || this.getVar("implicit_control_flow")) {
      this.skipTrueBranch(true);
      return;
    }
    throw new Error(this.lineMsg() + "It is illegal to fall in to an *else statement; you must *goto or *finish before the end of the indented block.");
};

// break the string up into a stack of tokens, defined in Scene.tokens below
Scene.prototype.tokenizeExpr = function tokenizeExpr(str) {
    var stack = [];
    var tokenTypes = Scene.tokens;
    var tokenTypesLength = tokenTypes.length;
    var pos = 0;
    while (str) {
        var matched = false;
        for (var i = 0; i < tokenTypesLength; i++) {
            var tokenType = tokenTypes[i];
            var token = tokenType.test(str, this.lineNum+1, this);
            if (token) {
                matched = true;
                str = str.substr(token.length);
                pos += token.length;
                var item = {name:tokenType.name, value:token, pos:pos};
                if (this.testPath && tokenType.name === "VAR" && /^choice_saved_checkpoint/i.test(token)) {
                  // handle choice_saved_checkpoint in quicktest
                  this.stats[token.toLowerCase()] = true;
                }
                if ("WHITESPACE" == tokenType.name) {
                    break;
                } else if ("CURLY_QUOTE" == tokenType.name) {
                  throw new Error(this.lineMsg()+"Invalid use of curly smart quote: " + token + "\nUse straight quotes \" instead")
                } else if ("FUNCTION" == tokenType.name) {
                  item.func = /^\w+/.exec(token)[0];
                }
                stack.push(item);
                break;
            }
        }
        if (!matched) throw new Error(this.lineMsg()+"Invalid expression, couldn't extract another token: " + str);
    }
    return stack;
};

// evaluate the stack of tokens
// parenthetical == true if we're evaluating a parenthetical expression
// all expressions consist of either a "singleton" value (2) or two values and one operator (2+2)
Scene.prototype.evaluateExpr = function evaluateExpr(stack, parenthetical) {
    if (!stack.length) {
        throw new Error(this.lineMsg() + "no expression specified");
    }
    var self = this;
    function getToken() {
        var token = stack.shift();
        if (!token) throw new Error(self.lineMsg() + "null token");
        return token;
    }

    var token, value1, value2, operator, result;

    value1 = this.evaluateValueToken(getToken(), stack);

    if (!stack.length) {
        if (parenthetical) {
            throw new Error(this.lineMsg() + "Invalid expression, expected " + parenthetical);
        }
        return value1;
    }

    token = getToken();

    if (parenthetical && parenthetical == token.name) {
        return value1;
    }

    // Since this isn't a singleton, it must be an operator
    operator = Scene.operators[token.value];
    if (!operator) {
      throw new Error(this.lineMsg() + "Invalid expression at char "+token.pos+", expected OPERATOR"+
        (parenthetical?" or " + parenthetical : "")+
        ", was: " + token.name + " [" + token.value + "]");
    }

    if (token.value === '%') {
      this.warning("this is a bare % sign, which should be replaced with %+, %-, or modulo if you're really advanced.");
      this.warning("For more details on modulo, see: https://forum.choiceofgames.com/t/21176");
    }

    if (!stack[0]) {
      throw new Error(this.lineMsg() + "Invalid expression at char "+token.pos+", expected something after a "+token.value);
    }

    if (stack[0].func == "auto") {
      value2 = this.autobalance(stack, token, value1);
    } else {
      value2 = this.evaluateValueToken(getToken(), stack);
    }

    // and do the operator
    result = operator(value1, value2, this.lineNum+1, this);

    if (parenthetical) {
        // expect close parenthesis
        if (stack.length) {
            token = getToken();
            if (parenthetical == token.name) {
                return result;
            } else {
                throw new Error(this.lineMsg() + "Invalid expression at char "+token.pos+", expected "+parenthetical+", was: " + token.name + " [" + token.value + "]");
            }
        } else {
            throw new Error(this.lineMsg() + "Invalid expression, expected " + parenthetical);
        }
    } else {
        // if not parenthetical, expect no more tokens
        if (stack.length) {
            token = getToken();
            throw new Error(this.lineMsg() + "Invalid expression at char "+token.pos+", expected no more tokens, found: " + token.name + " [" + token.value + "]");
        } else {
            return result;
        }
    }
    throw new Error(this.lineMsg() + "bug, how did I get here?");
};

// turn a number, string, or var token into its value
// or, if this is an open parenthesis, evaluate the parenthetical expression
Scene.prototype.evaluateValueToken = function evaluateValueToken(token, stack) {
    var name = token.name;
    var value;
    if ("OPEN_PARENTHESIS" == name) {
        return this.evaluateExpr(stack, "CLOSE_PARENTHESIS");
    } else if ("OPEN_CURLY" == name) {
        value = this.evaluateExpr(stack, "CLOSE_CURLY");
        return this.getVar(value);
    } else if ("FUNCTION" == name) {
        if (!this.functions[token.func]) throw new Error(this.lineMsg + "Unknown function " + token.func);
        value = this.evaluateExpr(stack, "CLOSE_PARENTHESIS");
        return this.functions[token.func].call(this, value);
    } else if ("NUMBER" == name) {
        return token.value;
    } else if ("STRING" == name) {
        // strip off the quotes and unescape backslashes
        return this.replaceVariables(token.value.slice(1,-1).replace(/\\(.)/g, "$1"));
    } else if ("VAR" == name) {
        var variable = String(token.value);
        while (stack.length && stack[0].name == "OPEN_SQUARE") {
          stack.shift();
          variable += "_" + this.evaluateExpr(stack, "CLOSE_SQUARE");
        }
        return this.getVar(variable);
    } else {
        throw new Error(this.lineMsg() + "Invalid expression at char "+token.pos+", expected NUMBER, STRING, VAR or PARENTHETICAL, was: " + name + " [" + token.value + "]");
    }
};

// turn a var token into its name, remove it from the stack
// or if it's a curly parenthesis, evaluate that
// or if it's an array expression, convert it into its raw underscore name
Scene.prototype.evaluateReference = function evaluateReference(stack, options) {
  var toLowerCase = true;
  if (options && options.hasOwnProperty("toLowerCase")) toLowerCase = !!options.toLowerCase;
  function findClosingBracket(stack, type, offset) {
    if (!offset) offset = 0;
    var opens = 0;
    var openType = "OPEN_"+type;
    var closeType = "CLOSE_"+type;
    for (var i = offset; i < stack.length; i++) {
      if (stack[i].name == openType) {
        opens++;
      } else if (stack[i].name == closeType) {
        if (opens) {
          opens--;
        } else {
          return i;
        }
      }
    }
    return -1;
  }
  function normalizeCase(name) {
    if (toLowerCase) {
      return String(name).toLowerCase();
    } else {
      return name;
    }
  }
  if (!stack.length) throw new Error(this.lineMsg()+"Invalid expression, expected a name");
  var name;
  if (stack[0].name === "OPEN_CURLY") {
    stack.shift();
    var closingCurly = findClosingBracket(stack, "CURLY");
    if (closingCurly == -1) throw new Error(this.lineMsg()+"Invalid expression, no closing curly bracket: " + data);
    name = this.evaluateExpr(stack.slice(0, closingCurly));
    stack.splice(0, closingCurly+1);
    return normalizeCase(name);
  } else if (stack[0].name === "NUMBER") {
    // you could have a label that's just a number
    name = stack[0].value;
    stack.shift();
    return name;
  } else {
    if (stack[0].name !== "VAR") throw new Error(this.lineMsg() + "Invalid expression; expected name, found " + stack[0].name + " at char " + stack[0].pos);
    name = String(stack[0].value);
    stack.shift();
    while(stack.length && stack[0].name == "OPEN_SQUARE") {
      var closingBracket = findClosingBracket(stack, "SQUARE", 1);
      if (closingBracket == -1) throw new Error(this.lineMsg()+"Invalid expression, no closing array bracket at char " + stack[1].pos);
      var index = this.evaluateExpr(stack.slice(1, closingBracket));
      name += "_" + index;
      stack.splice(0, closingBracket+1);
    }
    return normalizeCase(name);
  }
};

Scene.prototype.functions = {
  not: function(value) {
    return !bool(value, this.lineNum+1);
  },
  round: function(value) {
    if (isNaN(value*1)) throw new Error(this.lineMsg()+"round() value is not a number: " + value);
    return Math.round(value);
  },
  timestamp: function(value) {
    return Date.parse(value)/1000;
  },
  log: function(value) {
    if (isNaN(value*1)) throw new Error(this.lineMsg()+"log() value is not a number: " + value);
    return Math.log(value)/Math.log(10);
  },
  length: function(value) {
    return String(value).length;
  },
  auto: function() {
    throw new Error(this.lineMsg()+"Invalid expression, auto() must come after a < or > symbol");
  }
};

Scene.prototype.autobalance = function autobalance(stack, operatorToken, value) {
  if (operatorToken.name !== "INEQUALITY") {
    throw new Error(this.lineMsg()+"Invalid expression, auto() must come after a < or > symbol");
  }
  stack.shift(); // remove auto function

  if (stack.length < 4 ||
    stack[0].name !== "NUMBER" ||
    stack[1].name !== "COMMA" ||
    !(stack[2].name == "VAR" || stack[2].name == "NUMBER") ||
    stack[3].name !== "CLOSE_PARENTHESIS"
  ) {
    throw new Error(this.lineMsg()+"Invalid expression, auto() requires (percentage, id)");
  }
  var rateString = stack.shift().value;
  var rate = parseFloat(rateString);
  if (isNaN(rate) || rate < 1 || rate > 99) {
    throw new Error(this.lineMsg()+"the first auto() parameter should be a number between 1 and 99: " + rateString);
  }
  stack.shift(); // comma
  var id = stack.shift().value;
  stack.shift(); // close parenthesis
  var result = this.stats['auto' + '_' + this.name + '_' + id];
  if (typeof result != "undefined") {
    return result;
  } else if (this.recordBalance) {
    return this.recordBalance(value, operatorToken.value, rate, id);
  }
  return 50;
}

Scene.prototype.evaluateValueExpr = function evaluateValueExpr(expr) {
    var stack = this.tokenizeExpr(expr);
    var token = stack.shift();
    if (!token) throw new Error(this.lineMsg() + "null token");
    var value = this.evaluateValueToken(token, stack);
    if (stack.length) {
        token = stack.shift();
        if (!token) throw new Error(this.lineMsg() + "null token");
        throw new Error(this.lineMsg() + "Invalid expression at char "+token.pos+", expected no more tokens, found: " + token.name + " [" + token.value + "]");
    }
    return value;
};

Scene.prototype.goto_random_scene = function gotoRandomScene(data) {
  var parsed = this.parseGotoRandomScene(data);
  var allowReuseGlobally = /\ballow_reuse\b/.test(data);
  var allowNoSelection = /\ballow_no_selection\b/.test(data);
  var option = this.computeRandomSelection(Math.random(), parsed, allowReuseGlobally);

  if (option) {
    this.goto_scene(option.name);
  } else {
    if (allowNoSelection) {
      return;
    } else {
      throw new Error(this.lineMsg() + "No selectable scenes");
    }
  }

};

Scene.prototype.parseGotoRandomScene = function parseGotoRandomScene(data) {
    data = data || "";
    var directives = data.split(" ");
    var allowReuseGlobally = false;
    var allowNoSelection = false;
    for (var i = 0; i < directives; i++) {
      var directive = trim(directives[i]);
      if (!directive) continue;
      if (directive == "allow_reuse") {
        allowReuseGlobally = true;
      } else if (directive == "allow_no_selection") {
        allowNoSelection = true;
      } else {
        throw new Error(this.lineMsg() + "invalid command: '" + directive + "'");
      }
    }

    // nextIndent: the level of indentation after the current line
    var nextIndent = null;
    var options = [];
    var line;
    var startIndent = this.indent;
    while(isDefined(line = this.lines[++this.lineNum])) {
        if (!trim(line)) {
            this.rollbackLineCoverage();
            continue;
        }
        var indent = this.getIndent(line);
        if (nextIndent === null || nextIndent === undefined) {
            // initialize nextIndent with whatever indentation the line turns out to be
            // ...unless it's not indented at all
            if (indent <= startIndent) {
                throw new Error(this.lineMsg() + "invalid indent, expected at least one line in *goto_random_scene");
            }
            this.indent = nextIndent = indent;
        }
        if (indent <= startIndent) {
            // it's over!
            this.rollbackLineCoverage();
            this.lineNum--;
            this.rollbackLineCoverage();
            break;
        }
        if (indent != this.indent) {
            // all chart rows are supposed to be at the same indentation level
            // anything at the wrong indentation level might be a mis-indented title/definition
            // or just a typo
            throw new Error(this.lineMsg() + "invalid indent, expected "+this.indent+", was " + indent);
        }
        line = trim(line);

        var option = {allowReuse:allowReuseGlobally};
        var command;
        while(!!(command = /^\*(\S+)/.exec(line))) {
          command = command[1];
          if ("allow_reuse" == command) {
            option.allowReuse = true;
            line = trim(line.substring("*allow_reuse".length));
            command = /^\*(\S+)/.exec(line);
            continue;
          } else if ("if" == command) {
            var conditional = /^\*if\s+\((.+)\)\s+([^\)]+)/.exec(line);
            if (!conditional) throw new Error(this.lineMsg() + " invalid *if, expected () followed by scene name: " + line);
            line = conditional[2];
            var stack = this.tokenizeExpr(conditional[1]);
            this.evaluateExpr(stack);
            option.conditional = conditional[1];
          } else {
            throw new Error(this.lineMsg() + " invalid command: " + line);
          }
        }
        // TODO weights
        option.name = trim(line);
        options.push(option);
    }
    return options;
};

Scene.prototype.computeRandomSelection = function computeRandomSelection(randomFloat, options, allowReuseGlobally) {
  var filtered = [];
  var finished = {};
  if (!allowReuseGlobally) {
    if (!this.stats.choice_grs) this.stats.choice_grs = [];
  }
  var grs = this.stats.choice_grs;
  for (var i = 0; i < grs.length; i++) {
    finished[grs[i]] = 1;
  }
  var option;
  for (i = 0; i < options.length; i++) {
    option = options[i];
    if (!option.allowReuse) {
      if (finished[option.name]) continue;
    }
    if (option.conditional) {
      var stack = this.tokenizeExpr(option.conditional);
      var pass = this.evaluateExpr(stack);
      if (!pass) continue;
    }
    filtered.push(option);
  }
  if (!filtered.length) return null;
  // TODO weights
  var randomSelection = Math.floor(randomFloat*filtered.length);
  option = filtered[randomSelection];
  if (!option.allowReuse) {
    this.stats.choice_grs.push(option.name);
  }
  return option;
};

Scene.prototype.end_trial = function endTrial() {
  this.paragraph();
  printLink(this.target, "#", "Start Over from the Beginning", function(e) {
    preventDefault(e);
    return restartGame("prompt");
  });
  this.prevLine = "block";
  this.screenEmpty = false;
  this.finished = true;
};

Scene.prototype.achieve = function scene_achieve(name) {
  name = name.toLowerCase();
  if (!this.nav.achievements.hasOwnProperty(name)) {
    throw new Error(this.lineMsg() + "the achievement name "+name+" was not declared as an *achievement in startup");
  }
  var achievement = this.nav.achievements[name];
  this.nav.achieved[name] = true;
  if (typeof achieve != "undefined") {
    achieve(name, achievement.title, achievement.earnedDescription);
  }
};

Scene.prototype.check_achievements = function scene_checkAchievements() {
  var self = this;
  function callback(immediately) {
    for (var achievement in nav.achievements) {

      self.temps["choice_achieved_"+achievement] = nav.achieved.hasOwnProperty(achievement);
    }
    if (!immediately) {
      self.finished = false;
      self.skipFooter = false;
      self.execute();
    }
  }
  if (typeof checkAchievements == "undefined") {
    callback("immediately");
  } else {
    this.finished = true;
    this.skipFooter = true;
    checkAchievements(callback);
  }
};

Scene.prototype.scene_list = function scene_list() {
  var scenes = this.parseSceneList();
  this.nav.setSceneList(scenes);
};

Scene.prototype.parseSceneList = function parseSceneList() {
  var nextIndent = null;
  var scenes = [];
  var line;
  var startIndent = this.indent;
  while(isDefined(line = this.lines[++this.lineNum])) {
      if (!trim(line)) {
          this.rollbackLineCoverage();
          continue;
      }
      var indent = this.getIndent(line);
      if (nextIndent === null || nextIndent === undefined) {
          // initialize nextIndent with whatever indentation the line turns out to be
          // ...unless it's not indented at all
          if (indent <= startIndent) {
              throw new Error(this.lineMsg() + "invalid indent, expected at least one row");
          }
          this.indent = nextIndent = indent;
      }
      if (indent <= startIndent) {
          // it's over!
          this.rollbackLineCoverage();
          this.lineNum--;
          this.rollbackLineCoverage();
          return scenes;
      }
      if (indent != this.indent) {
          // all scenes are supposed to be at the same indentation level
          throw new Error(this.lineMsg() + "invalid indent, expected "+this.indent+", was " + indent);
      }

      line = trim(line);
      var purchaseMatch = /^\$(\w*)\s+(.*)/.exec(line);
      if (purchaseMatch) {
        line = purchaseMatch[2];
      }
      if (!scenes.length && "startup" != String(line).toLowerCase()) scenes.push("startup");
      scenes.push(line);
  }
  return scenes;
};

Scene.prototype.title = function scene_title(title) {
  this.stats.choice_title = trim(title);
  if (this.nav) this.nav.startingStats.choice_title = trim(title);
  if (typeof changeTitle != "undefined") {
    changeTitle(title);
  }
};

Scene.prototype.author = function scene_author(author) {
  if (typeof changeAuthor != "undefined") {
    changeAuthor(author);
  }
};

// *achievement name hidden|visible 100 Achievement Title
//     Earned description
//     Pre-earned description
Scene.prototype.achievement = function scene_achievement(data) {
  var parsed = /(\S+)\s+(\S+)\s+(\S+)\s+(.*)/.exec(data);
  if (!parsed) throw new Error(this.lineMsg() + "Invalid *achievement, requires short name, visibility, points, and display title: " + data);
  var achievementName = parsed[1];
  if (!/^[a-z][a-z0-9_]+$/.test(achievementName)) throw new Error(this.lineMsg()+"Invalid achievement name: " +achievementName);

  if (!this.parsedAnAchievment && Object.keys(this.nav.achievements).length > 0) {
    // blow away pre-existing mygame.js achievements
    this.nav.achievements = {};
    this.nav.achievementList = [];
    this.achievementTotal = 0;
    this.seenAchievementTitles = {};
  }

  this.parsedAnAchievment = true;

  if (this.nav.achievements.hasOwnProperty(achievementName)) {
    var preExisting = this.nav.achievements[achievementName];
    throw new Error(this.lineMsg()+"Achievement "+achievementName+" already defined on line " + this.nav.achievements[achievementName].lineNumber);
  }

  var lineNumber = this.lineNum+1;
  var visibility = parsed[2];
  if (visibility != "hidden" && visibility != "visible") {
    throw new Error(this.lineMsg()+"Invalid *achievement, the second word should be either 'hidden' or 'visible': " +visibility);
  }
  var visible = (visibility != "hidden");
  var pointString = parsed[3];
  if (!/[1-9][0-9]*/.test(pointString)) {
    throw new Error(this.lineMsg()+"Invalid *achievement, the third word should be an integer number of points: " + pointString);
  }
  var points = parseInt(pointString, 10);
  if (points > 100) throw new Error(this.lineMsg()+"Invalid *achievement, no achievement may be worth more than 100 points: " + points);
  if (points < 1) throw new Error(this.lineMsg()+"Invalid *achievement, no achievement may be worth less than 1 point: " + points);
  if (!this.achievementTotal) this.achievementTotal = 0;
  this.achievementTotal += points;
  if (this.achievementTotal > 1000) {
    throw new Error(this.lineMsg()+"Invalid achievements. Adding " + points + " would add up to more than 1,000 points: " + this.achievementTotal);
  }
  var title = parsed[4];
  if (/(\$\{)/.test(title)) throw new Error(this.lineMsg()+"Invalid *achievement. ${} not permitted in achievement title: " + title);
  if (/(\@\{)/.test(title)) throw new Error(this.lineMsg()+"Invalid *achievement. @{} not permitted in achievement title: " + title);
  if (/(\[)/.test(title)) throw new Error(this.lineMsg()+"Invalid *achievement. [] not permitted in achievement title: " + title);
  if (title.length > 50) throw new Error(this.lineMsg()+"Invalid *achievement. Title must be 50 characters or fewer: " + title);

  // Get the description from the next indented line
  var line = this.lines[++this.lineNum];
  var indent = this.getIndent(line);
  if (!indent) {
    throw new Error(this.lineMsg()+"Invalid *achievement. An indented description is required.");
  }
  var preEarnedDescription = trim(line);
  if (/(\$\{)/.test(preEarnedDescription)) throw new Error(this.lineMsg()+"Invalid *achievement. ${} not permitted in achievement description: " + preEarnedDescription);
  if (/(\@\{)/.test(preEarnedDescription)) throw new Error(this.lineMsg()+"Invalid *achievement. @{} not permitted in achievement description: " + preEarnedDescription);
  if (/(\[)/.test(preEarnedDescription)) throw new Error(this.lineMsg()+"Invalid *achievement. [] not permitted in achievement description: " + preEarnedDescription);
  if (preEarnedDescription.length > 200) throw new Error(this.lineMsg()+"Invalid *achievement. Pre-earned description must be 200 characters or fewer: " + preEarnedDescription);

  if (!visible) {
    if (preEarnedDescription.toLowerCase() != "hidden") throw new Error(this.lineMsg()+"Invalid *achievement. Hidden achievements must set their pre-earned description to 'hidden'.");
  }

  // Optionally get a post-earned description from the next line
  var postEarnedDescription = null;
  while(isDefined(line = this.lines[++this.lineNum])) {
    if (trim(line)) break;
    this.rollbackLineCoverage();
  }
  indent = this.getIndent(line);
  if (indent) {
    postEarnedDescription = trim(line);
    if (/(\$\{)/.test(postEarnedDescription)) throw new Error(this.lineMsg()+"Invalid *achievement. ${} not permitted in achievement description: " + postEarnedDescription);
    if (/(\@\{)/.test(postEarnedDescription)) throw new Error(this.lineMsg()+"Invalid *achievement. @{} not permitted in achievement description: " + postEarnedDescription);
    if (/(\[)/.test(postEarnedDescription)) throw new Error(this.lineMsg()+"Invalid *achievement. [] not permitted in achievement description: " + postEarnedDescription);
    if (postEarnedDescription.length > 200) throw new Error(this.lineMsg()+"Invalid *achievement. Post-earned description must be 200 characters or fewer: " + postEarnedDescription);
  } else {
    // No indent means the next line is not a post-earned description
    this.rollbackLineCoverage();
    this.lineNum--;
    this.rollbackLineCoverage();
  }

  if (!postEarnedDescription) {
    if (!visible) throw new Error(this.lineMsg()+"Invalid *achievement. Hidden achievements must set a post-earned description.");
    postEarnedDescription = preEarnedDescription;
  }

  if (!this.nav.achievements.hasOwnProperty(achievementName)) {
    this.nav.achievementList.push(achievementName);
    if (this.nav.achievementList.length > 100) {
      throw new Error(this.lineMsg()+"Too many *achievements. Each game can have up to 100 achievements.");
    }
  }

  if (!this.seenAchievementTitles) this.seenAchievementTitles = {};

  if (this.seenAchievementTitles[title]) {
    throw new Error(this.lineMsg()+"An achievement with display title \"" + title + "\" was already defined at line " + this.seenAchievementTitles[title]);
  }

  this.seenAchievementTitles[title] = this.lineNum+1;

  this.nav.achievements[achievementName] = {
    visible: visible,
    points: points,
    title: title,
    earnedDescription: postEarnedDescription,
    preEarnedDescription: preEarnedDescription,
    lineNumber: lineNumber
  };

  if (typeof setButtonTitles != "undefined") setButtonTitles();
};


Scene.prototype.bug = function scene_bug(message) {
  if (message) {
    message = "Bug: " + this.replaceVariables(message);
  } else {
    message = "Bug";
  }
  if (message === "Bug: choice_beta" && _global.beta) return;
  throw new Error(this.lineMsg() + message);
};

Scene.prototype.warning = function scene_warning(message) {
  // quicktest implements this
}

Scene.prototype.feedback = function scene_feedback() {
  this.stats.choice_feedback_requested = true;
  if (typeof window == "undefined" || this.randomtest) return;
  this.paragraph();
  this.printLine("On a scale from 1 to 10, how likely are you to recommend this game to a friend?");
  this.paragraph();
  var options = [{name:"10 (Most likely)"}];
  for (var i = 9; i > 1; i--) {
    options.push({name:i});
  }
  options.push({name:"1 (Least likely)"});
  options.push({name:"No response."});
  var self = this;
  if (window.prepareReviewPrompt) window.prepareReviewPrompt();
  this.renderOptions([""], options, function(option) {
    var value = "null";
    var numberMatch = /^(\d+)/.exec(option.name);
    if (numberMatch) value = numberMatch[1]*1;
    if (!isWebSavePossible()) {
      self.finished = false;
      self.resetPage();
      return;
    }

    var postFeedback = function() {
      xhrAuthRequest("POST", "feedback", function(ok, response) {
        if (window.console) console.log("ok", ok, response);
      }, "game", window.storeName, "platform", platformCode(), "rating", value);
      if (/^(9|10)/.test(option.name)) {
        if (isReviewSupported()) {
          return clearScreen(function() {
            if (promptForReview()) {
              self.screenEmpty = false;
              self.prevLine = "text";
              self.page_break();
              printFooter();
            } else {
              self.finished = false;
              self.resetPage();
            }
          });
        }
      }
      self.finished = false;
      self.resetPage();
    }

    if (value == "null") {
      self.finished = false;
      self.resetPage();
      return;
    }

    isRegistered(function(registered) {
      if (registered) {
        postFeedback();
      } else {
        clearScreen(function() {
          loginForm(main, 1/*optional*/, "Please sign in to have your vote counted!", postFeedback);
        });
      }
    });
  });
  this.finished = true;
};

Scene.prototype.parseTrackEvent = function(data) {
  var event = {};
  var stack = this.tokenizeExpr(data);
  if (!stack.length) throw new Error(this.lineMsg() + "Invalid track_event statement, expected at least two args: category and action");
  event.category = this.evaluateValueToken(stack.shift(), stack);
  if (!stack.length) throw new Error(this.lineMsg() + "Invalid track_event statement, expected at least two args: category and action");
  event.action = this.evaluateValueToken(stack.shift(), stack);
  if (stack.length) {
    event.label = this.evaluateValueToken(stack.shift(), stack);
    if (stack.length) {
      event.value = this.evaluateValueToken(stack.shift(), stack);
      if (stack.length) {
        throw new Error(this.lineMsg() + "Invalid track_event statement, expected at most four args: category, action, label, value");
      }
      var intValue = parseInt(event.value, 10);
      if (isNaN(intValue) || event.value != intValue || event.value.toString() != intValue.toString()) {
        throw new Error(this.lineMsg() + "Invalid track_event statement, value must be an integer: " + event.value);
      }
    }
  }
  return event;
}

Scene.prototype.track_event = function track_event(data) {
  var event = this.parseTrackEvent(data);
  if (typeof ga !== "undefined") {
    ga('send', 'event', event.category, event.action, event.label, event.value);
  }
}

Scene.prototype.ai = function ai(data) {}

Scene.prototype.config = function config(data) {
    var stack = this.tokenizeExpr(data);
    var variable = this.evaluateReference(stack);
    if ("undefined" === typeof this.temps[variable] && "undefined" === typeof this.stats[variable]) {
      throw new Error(this.lineMsg() + "Non-existent variable '"+variable+"'");
    }
    if (stack.length === 0) throw new Error(this.lineMsg()+"Invalid set instruction, no expression specified: " + line);
    var value = this.evaluateExpr(stack);
    this.setVar(variable, value);
    if ("undefined" !== typeof remoteConfig && !this.randomtest && !this.quicktest) {
      this.finished = true;
      this.skipFooter = true;
      var self = this;
      remoteConfig(variable, function(result) {
        if (result !== null) self.setVar(variable, result);
        self.finished = false;
        self.skipFooter = false;
        self.execute();
      })
    }
};

Scene.prototype.ifid = function ifid(id) {
  if (!/^\w{8}-\w{4}-\w{4}-\w{4}-\w{12}$/.test(id)) {
    var example = "a0a0a0a0-a0a0-a0a0-a0a0-a0a0a0a0a0a0";
    try {
      example = crypto.randomUUID();
    } catch (e) {}
    throw new Error(this.lineMsg() + "Invalid IFID. It should have five parts, like \""+example+"\": " + id);
  }
  if (!/^[A-F0-9\-]{36}$/i.test(id)) {
    throw new Error(this.lineMsg() + "Invalid IFID. It should contain only numbers, letters A-F, and dashes: " + id);
  }
}

Scene.prototype.lineMsg = function lineMsg() {
    return this.name + " line " + (this.lineNum+1) + ": ";
};

Scene.prototype.rollbackLineCoverage = function() {};

Scene.baseUrl = "scenes";
Scene.regexpMatch = function regexpMatch(str, re) {
    var result = re.exec(str);
    if (!result) return null;
    return result[0];
};
// Each token has a name and a test, which returns the matching string
Scene.tokens = [
    {name:"OPEN_PARENTHESIS", test:function(str){ return Scene.regexpMatch(str,/^\(/); } },
    {name:"CLOSE_PARENTHESIS", test:function(str){ return Scene.regexpMatch(str,/^\)/); } },
    {name:"OPEN_CURLY", test:function(str){ return Scene.regexpMatch(str,/^\{/); } },
    {name:"CLOSE_CURLY", test:function(str){ return Scene.regexpMatch(str,/^\}/); } },
    {name:"OPEN_SQUARE", test:function(str){ return Scene.regexpMatch(str,/^\[/); } },
    {name:"CLOSE_SQUARE", test:function(str){ return Scene.regexpMatch(str,/^\]/); } },
    {name:"FUNCTION", test:function(str){ return Scene.regexpMatch(str,/^(not|round|timestamp|log|length|auto)\s*\(/); } },
    {name:"NUMBER", test:function(str){ return Scene.regexpMatch(str,/^\d+(\.\d+)?\b/); } },
    {name:"STRING", test:function(str, line, sceneObj) {
            var i;
            if (!/^\"/.test(str)) return null;
            for (i = 1; i < str.length; i++) {
                var x = str.charAt(i);
                if ("\\" == x) {
                    i++;
                } else if ('"' == x) {
                    return str.substring(0,i+1);
                }
            }
            var errMessage = "line " + line + ": ";
            if (sceneObj) {
              errMessage = sceneObj.lineMsg();
            }
            throw new Error(errMessage+"Invalid string, open quote with no close quote: " + str);
        }
    },
    {name:"CURLY_QUOTE", test:function(str){ return Scene.regexpMatch(str,/^[\u201c\u201d]/); } },
    {name:"WHITESPACE", test:function(str){ return Scene.regexpMatch(str,/^\s+/); } },
    {name:"NAMED_OPERATOR", test:function(str){ return Scene.regexpMatch(str,/^(and|or|modulo)\b/); } },
    {name:"VAR", test:function(str){ return Scene.regexpMatch(str,/^\w*/); } },
    {name:"FAIRMATH", test:function(str){ return Scene.regexpMatch(str,/^%[\+\-]/); } },
    {name:"OPERATOR", test:function(str){ return Scene.regexpMatch(str,/^[\+\-\*\/\&\%\^\#]/); } },
    {name:"INEQUALITY", test:function(str){ return Scene.regexpMatch(str,/^[\!<>]\=?/); } },
    {name:"EQUALITY", test:function(str){ return Scene.regexpMatch(str,/^=/); } },
    {name:"COMMA", test:function(str){ return Scene.regexpMatch(str,/^,/); } }
    //
];
Scene.operators = {
    "+": function add(v1,v2,line,sceneObj) { var name = null; if (sceneObj) name = sceneObj.name; return num(v1,line,name) + num(v2,line,name); },
    "-": function subtract(v1,v2,line,sceneObj) { var name = null; if (sceneObj) name = sceneObj.name; return num(v1,line,name) - num(v2,line,name); },
    "*": function multiply(v1,v2,line,sceneObj) { var name = null; if (sceneObj) name = sceneObj.name; return num(v1,line,name) * num(v2,line,name); },
    "/": function divide(v1,v2,line,sceneObj) {
      var name = null; if (sceneObj) name = sceneObj.name; 
      v2 = num(v2, line, name);
      if (v2 === 0) throw new Error(name+" line "+line+": can't divide by zero");
      return num(v1,line,name) / num(v2,line,name);
    },
    "^": function exponent(v1,v2,line,sceneObj) { var name = null; if (sceneObj) name = sceneObj.name; return Math.pow(num(v1,line,name), num(v2,line,name)); },
    "&": function concatenate(v1,v2) { return [v1,v2].join(""); },
    "#": function charAt(v1,v2,line,sceneObj) {
      var name = null;
      var errMessage = "line " + line + ": ";
      if (sceneObj) {
        name = sceneObj.name;
        errMessage = sceneObj.lineMsg();
      }
      var i = num(v2,line,name);
      if (i < 1) {
        throw new Error(errMessage+"There is no character at position " + i + "; the position must be greater than or equal to 1.");
      }
      if (i > String(v1).length) {
        throw new Error(errMessage+"There is no character at position " + i + ". \""+v1+"\" is only " + String(v1).length + " characters long.");
      }
      return String(v1).charAt(i-1);
    },
    "%+": function fairAdd(v1, v2, line, sceneObj) {
        var name = null;
        var errMessage = "line " + line + ": ";
        if (sceneObj) {
          name = sceneObj.name;
          errMessage = sceneObj.lineMsg();
        }
        v1 = num(v1,line,name);
        v2 = num(v2,line,name);
        var validValue = (v1 >= 0 && v1 <= 100);
        if (!validValue) {
            throw new Error(errMessage+"Can't fairAdd to non-percentile value: " + v1);
        }
        if (v2 > 0) {
          var multiplier = (100 - v1) / 100;
          var actualModifier = v2 * multiplier;
          var value = 1 * v1 + actualModifier;
          value = Math.floor(value);
          if (value > 99) value = 99;
          return value;
        } else {
          var multiplier = v1 / 100;
          var actualModifier = (0-v2) * multiplier;
          var value = v1 - actualModifier;
          value = Math.ceil(value);
          if (value < 1) value = 1;
          return value;
        }
    },
    "%-": function fairSubtract(v1, v2, line, sceneObj) {
        var name = null; if (sceneObj) name = sceneObj.name; 
        v2 = num(v2,line,name);
        return Scene.operators["%+"](v1,0-v2,line,sceneObj);
    },
    "=": function equals(v1,v2) { return v1 == v2 || String(v1) == String(v2); },
    "<": function lessThan(v1,v2,line,sceneObj) {
        var name = null; if (sceneObj) name = sceneObj.name; 
        return num(v1,line,name) < num(v2,line,name); },
    ">": function greaterThan(v1,v2,line,sceneObj) { var name = null; if (sceneObj) name = sceneObj.name; return num(v1,line,name) > num(v2,line,name); },
    "<=": function lessThanOrEquals(v1,v2,line,sceneObj) { var name = null; if (sceneObj) name = sceneObj.name; return num(v1,line,name) <= num(v2,line,name); },
    ">=": function greaterThanOrEquals(v1,v2,line,sceneObj) { var name = null; if (sceneObj) name = sceneObj.name; return num(v1,line,name) >= num(v2,line,name); },
    "!=": function notEquals(v1,v2) { return v1 != v2; },
    "and": function and(v1, v2, line, sceneObj) {
        var name = null; if (sceneObj) name = sceneObj.name; 
        return bool(v1,line,name) && bool(v2,line,name);
    },
    "or": function or(v1, v2, line, sceneObj) {
        var name = null; if (sceneObj) name = sceneObj.name; 
        return bool(v1,line,name) || bool(v2,line,name);
    },
    "modulo": function modulo(v1,v2,line,sceneObj) { var name = null; if (sceneObj) name = sceneObj.name; return num(v1,line,name) % num(v2,line,name); },
};

Scene.initialCommands = {"create":1,"create_array":1,"scene_list":1,"title":1,"author":1,"comment":1,"achievement":1,"product":1,"ifid":1};

Scene.validCommands = {"comment":1, "goto":1, "gotoref":1, "label":1, "looplimit":1, "finish":1, "abort":1,
    "choice":1, "create":1, "create_array": 1, "temp":1, "temp_array": 1, "delete":1, "delete_array":1, "set":1, "setref":1, "print":1, "if":1, "rand":1,
    "page_break":1, "line_break":1, "script":1, "else":1, "elseif":1, "elsif":1, "reset":1,
    "goto_scene":1, "fake_choice":1, "input_text":1, "ending":1, "share_this_game":1, "stat_chart":1,
    "subscribe":1, "show_password":1, "gosub":1, "return":1, "hide_reuse":1, "disable_reuse":1, "allow_reuse":1,
    "check_purchase":1,"restore_purchases":1,"purchase":1,"restore_game":1,"advertisement":1,
    "kindle_search":1,"kindle_product":1,"feedback":1,
    "save_game":1,"delay_break":1,"image":1,"kindle_image":1,"link":1,"input_number":1,"goto_random_scene":1,
    "restart":1,"more_games":1,"delay_ending":1,"end_trial":1,"login":1,"achieve":1,"scene_list":1,"title":1,
    "bug":1,"link_button":1,"check_registration":1,"sound":1,"author":1,"gosub_scene":1,"achievement":1,
    "check_achievements":1,"redirect_scene":1,"print_discount":1,"purchase_discount":1,"track_event":1,
    "timer":1,"youtube":1,"product":1,"text_image":1,"ai":1,"params":1,"config":1,"ifid":1,
    "page_break_advertisement":1, "finish_advertisement":1, "save_checkpoint": 1, "restore_checkpoint": 1
    };
/*
 * Copyright 2010 by Dan Fabulich.
 * 
 * Dan Fabulich licenses this file to you under the
 * ChoiceScript License, Version 1.0 (the "License"); you may
 * not use this file except in compliance with the License. 
 * You may obtain a copy of the License at
 * 
 *  http://www.choiceofgames.com/LICENSE-1.0.txt
 * 
 * See the License for the specific language governing
 * permissions and limitations under the License.
 * 
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
 * either express or implied.
 */
function SceneNavigator(sceneList) {
    this.setSceneList(sceneList);
    this.startingStats = {};
}

SceneNavigator.prototype.setSceneList = function setSceneList(sceneList) {
    this._sceneList = sceneList;
    this._sceneMap = {};
    for (var i = 0; i < sceneList.length-1; i++) {
        var scene1 = sceneList[i];
        var scene2 = sceneList[i+1];
        this._sceneMap[scene1] = scene2;
    }
    this._startupScene = sceneList[0];
};

SceneNavigator.prototype.nextSceneName = function nextSceneName(currentSceneName) {
    var nextScene = this._sceneMap[currentSceneName];
    //if (!nextScene) throw new Error("No scene follows " + currentSceneName);
    return nextScene;
};

SceneNavigator.prototype.getStartupScene = function getStartupScene() {
    return this._startupScene;
};

SceneNavigator.prototype.setStartingStatsClone = function setStartingStatsClone(stats) {
  this.startingStats = {};
  for (var i in stats) {
    this.startingStats[i] = stats[i];
  }
};

SceneNavigator.prototype.resetStats = function resetStats(stats) {
  for (var i in stats) {
    delete stats[i];
  }
  for (i in this.startingStats) {
    stats[i] = this.startingStats[i];
  }
  this.bugLog = [];
};

SceneNavigator.prototype.repairStats = function repairStats(stats) {
  for (var i in this.startingStats) {
    var startingStat = this.startingStats[i];
    if (startingStat === null || startingStat === undefined) continue;
    if (typeof(stats[i]) === "undefined" || stats[i] === null) {
      stats[i] = this.startingStats[i];
    }
  }
};

SceneNavigator.prototype.bugLog = [];
SceneNavigator.prototype.achievements = {};
SceneNavigator.prototype.achievementList = [];
SceneNavigator.prototype.achieved = {};
SceneNavigator.prototype.products = {};

SceneNavigator.prototype.loadAchievements = function(achievementArray) {
  if (!achievementArray) return;
  this.achievements = {};
  this.achievementList = [];
  for (var i = 0; i < achievementArray.length; i++) {
    var achievement = achievementArray[i];
    var achievementName = achievement[0];
    var visible = achievement[1];
    var points = achievement[2];
    var title = achievement[3];
    var earnedDescription = achievement[4];
    var preEarnedDescription = achievement[5];
    this.achievements[achievementName] = {
      visible: visible,
      points: points,
      title: title,
      earnedDescription: earnedDescription,
      preEarnedDescription: preEarnedDescription
    };
    this.achievementList.push(achievementName);
  }
};
SceneNavigator.prototype.loadProducts = function(productArray, purchaseMap) {
  if (!productArray && !purchaseMap) return;
  this.products = {};
  for (var i = 0; i < productArray; i++) {
    this.products[productArray[i]] = {};
  }
  for (var scene in purchaseMap) {
    var product = purchaseMap[scene];
    this.products[product] = {};
  }
}
nav = new SceneNavigator(["startup"]);
stats = {};

</script><style>html {
  font: -apple-system-body; /* we'll override the font, but keep the size from iOS dynamic text sizing */
}

body {
  position: relative;
  max-width: 80ch;
  min-height: 100vh; /* keep iOS address bar from popping in and out */
  font-size: 100%;  /* reset for CWS */
  font-family: Palatino,Cambria,Georgia,"Times New Roman",serif;
  background-color: #F7F4F1;
  color: rgba(0, 0, 0, 0.85);
  margin: 1ch auto;
  padding: 0;
  -webkit-user-select: text; /* selectable text for Chrome app support */
  transition-property: background-color, color;
  transition-duration: 2s;
  -webkit-transition-property: background-color, color;
  -webkit-transition-duration: 2s;
}

a {
  /* colored underlined links for XULRunner support */
  color: blue;
  text-decoration: underline;
  cursor: pointer;
}

#main {
	line-height: 1.5;
}

.container {
  position: absolute; /* so containers can overlap */
  left: 0;
  right: 0;
  margin: 0 1ch;
  animation-duration: 0.5s;
  -webkit-animation-duration: 0.5s;
  transition-property: opacity;
  transition-duration: 0.5s;
  transition-timing-function: ease-in;
  -webkit-transition-property: opacity;
  -webkit-transition-duration: 0.5s;
  -webkit-transition-timing-function: ease-in;
}

@keyframes containerslidein {
  from {
    transform: translateX(100%);
  }

  to {
    transform: none;
  }
}

@-webkit-keyframes containerslidein {
  from {
    -webkit-transform: translateX(100%);
  }

  to {
    -webkit-transform: none;
  }
}

.tempfocus:focus {
  outline: none;
}

#loading {
  position: fixed;
  bottom: 0;
  right: 0;
}

.frozen {
  overflow-x: hidden;
  pointer-events: none;
}

#text img {
  max-width: 100%;
}

/*credits*/
#main.container img {
  max-width: 100%;
}

.statBar {
  background-color: #949291;
  height: 2rem;
  line-height: 2rem;
  margin: 0.5ch 0;
  width: 20rem;
  max-width: 100%;
  color: #f7f4f1;
  position: relative; /* to allow absolute positioned value */
  z-index: 0;
}
.opposed {
  background-color: #6D6DFC;
}

table {
  margin-bottom: 1.5em;
}

.statText {
  margin-left: 2ex;
  text-indent: -1ex;
}

.statBar > span, .statLine > span {
  position: relative;
  z-index: 1; /* visible over stat value */
  white-space: nowrap; /* remain on single line so we can resize font based on width */
}
.statValue {
  background-color: #ff5955;
  position: absolute;
  top: 0;
  left: 0;
  height: 100%;
  z-index: -1;
  /* width will be determined at runtime, 0-100% */
}

.choice {
  margin: 1rem 0;
}

.choice > div {
  position: relative; /* so the .shuttle can be positioned absolutely within it */
  min-height: 1.25cm;
  display: flex;
  flex-direction: column;
  justify-content: center;
  border-color:#a9acaf;
  border-style:solid;
  border-width: 1px 1px 0px 1px;
}

.choice label{
    transition-property: background-color, color;
    transition-duration: 0.1s;
    -webkit-transition-property: background-color, color;
    -webkit-transition-duration: 0.1s;
    -webkit-tap-highlight-color: transparent;
    padding: 0.5em 1ch;
    display:block;
}

.shuttle {
  position: absolute;
  right: 0px;
  top: 0px;
  width: 20%;
  height: 100%;
  background-color: #626160; /* match next */
  opacity: 0.25;
  transition-property: opacity;
  transition-duration: 0.3s;
  -webkit-transition-property: opacity;
  -webkit-transition-duration: 0.3s;
}

.shuttle.discovery {
  width: 20%;
  animation-name: shuttlefadein, shuttleslide, shuttlefadeout;
  animation-duration: 0.75s, 1s, 1s;
  animation-delay: 0s, 0.75s, 1.75s;
  -webkit-animation-name: shuttlefadein, shuttleslide, shuttlefadeout;
  -webkit-animation-duration: 0.75s, 1s, 1s;
  -webkit-animation-delay: 0s, 0.75s, 1.75s;
}

@keyframes shuttlefadein {
  from {opacity: 0;}
  to {opacity: 0.25;}
}

@keyframes shuttlefadeout {
  from {opacity: 0.25; transform: translateX(-401%);} /* borders, maybe? */
  to {opacity: 0; transform: translateX(-401%);}
}

@keyframes shuttleslide {
  from { opacity: 0.25; transform: translateX(0);}
  to { opacity: 0.25; transform: translateX(-401%); }
}

@-webkit-keyframes shuttlefadein {
  from {opacity: 0;}
  to {opacity: 0.25;}
}

@-webkit-keyframes shuttlefadeout {
  from {opacity: 0.25; -webkit-transform: translateX(-401%);} /* borders, maybe? */
  to {opacity: 0; -webkit-transform: translateX(-401%);}
}

@-webkit-keyframes shuttleslide {
  from { opacity: 0.25; -webkit-transform: translateX(0);}
  to { opacity: 0.25; -webkit-transform: translateX(-401%); }
}

.selected {
  color: rgba(255,255,255,0.85);
  background-color: #007AFF;
}

.selectedKeyboard, .choice>div.selectedKeyboard:hover, .next.selectedKeyboard {
  color: rgba(255, 255, 255, 0.85);
  background-color: #007AFF;
  transition-property: background-color, color;
  transition-duration: 0.5s;
  transition-timing-function: linear;
  -webkit-transition-property: background-color, color;
  -webkit-transition-duration: 0.5s;
  -webkit-transition-timing-function: linear;
}

.choice>div:first-child{
    border-top-width:1px;
    -webkit-border-top-right-radius:1ch;
    -webkit-border-top-left-radius:1ch;
    border-top-right-radius:1ch;
    border-top-left-radius:1ch;
}
/* IE doesn't support label:last-child */
.choice>div:last-child{
    border-bottom-width:1px;
    -webkit-border-bottom-right-radius:1ch;
    -webkit-border-bottom-left-radius:1ch;
    border-bottom-right-radius:1ch;
    border-bottom-left-radius:1ch;
}

.choice .noBorder {
  border-width: 0;
}

input[type="radio"], input[type="checkbox"] {
  margin-right: 1ch;
}

.saveGame>label {
  display:block;
}

.choice .disabled {
  color: gray;
}

input[type=password]:disabled {
  background-color: lightgray;
}

/* Reset for Firefox vs. Chrome */
input[type=email],input[type=password] {
  padding: 1px;
  margin: 2px 0;
}

button {
  font-size: 100%;
}

.next {
    clear: both;
    display:block;
    width:100%;
    font-size:1.5em;
    font-weight:bolder;
    font-family: -apple-system, sans-serif; /* reset, for Android */
    margin: 1rem 0; /* reset button margin */
    -webkit-appearance: none; /* Safari, don't override my CSS styles */
    color: #f7f4f1; /* Match background color */ 
    background-color: #626160;
    border: none;
    -webkit-border-radius: 0.5em;
    -moz-border-radius: 0.5em;
    border-radius: 0.5em;
    padding: 6px;
}

.linkButton {
  text-align: center;
  text-decoration: none;
}

.next:hover {
  color: #E4DED8;
}

h1 {
  font-size: 1.5em;
  font-weight: normal;
}

h2 {
  font-size: 1.125em;
  font-weight: normal;
}

#identity {
  float: right;
}

#identity > a {
  display: block;
  text-align: end;
}

#footer {
  margin:10px 0px 75px 0px;
}

#mobileLinks a img {
  border: 0;
}

.mobileBadges {
  margin: 0;
}

.spacedLink {
  margin-right:0.5em;
}

.spacedLink:last-child {
  margin-right:0;
}

#sharelist {
  margin: 0; /* Eliminate leading space before share links */
}

#sharelist li {
  line-height: 1cm; /* Don't let the links bunch up */
}

.alertify-cover {
  background-color: black;
  filter:alpha(opacity=50);
  opacity: 0.5;
}

#greybackground {
    position: fixed;
    width:100%;
    height:100%;
    background-color: black;
    filter:alpha(opacity=50);
    opacity: 0.5;
    top:0;
    left:0;
}

.savePassword {
  font-family: monospace;
  display: block;
}

.webOnly { /* We'll override this in JavaScript */
  display: none;
}

.alignleft {
  display: inline;
  float: left;
  margin-right: 1.625em;
  margin-bottom: 1.5em;
}
.alignright {
  display: inline;
  float: right;
  margin-left: 1.625em;
  margin-bottom: 1.5em;
}
.aligncenter {
  clear: both;
  display: block;
  margin-left: auto;
  margin-right: auto;
  margin-bottom: 1.5em;
}

#main form {
  clear: both;
}

.paidOnly {
  display: none;
}

.videoWrapper {
  position: relative;
  padding-bottom: 56.25%; /* 16:9 */
  height: 0;
}

.videoWrapper iframe {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}

body.nightmode {
  background-color: #242424;
  color: rgba(255,255,255,0.85);
}

body.nightmode a {
  color: #7bc8fa;
}

body.nightmode .next {
  color: #242424; /* Match background color */
  background-color: #D9D9D9;
}

body.nightmode .shuttle {
  background-color: #D9D9D9;
}

body.nightmode .next:hover {
  color: #555;
}

body.nightmode .alertify {
  color: black;
}

body.nightmode a.alertify-button {
  color: white;
}

body.nightmode img.invert {
  -webkit-filter: invert(100%);
  filter: invert(100%);
}

body.whitemode {
  background-color: white;
}

body.whitemode .next {
  color: white; /* Match background color */
}

body.whitemode .next:hover {
  color: #ddd;
}

/* hide <dialog> on old browsers */
dialog:not([open]) {
  display: none;
}

#keyboardShortcuts {
  font-family: system-ui;
}

#keyboardShortcuts::backdrop {
  background: rgba(0, 0, 0, 0.4);
}

#keyboardShortcuts header {
  display: flex;
  justify-content: space-between;
}

#keyboardShortcuts header span {
  font-weight: bold;
}

#keyboardShortcuts .table {
  display: grid;
  grid-template-columns: 1fr 1fr;
}

#keyboardShortcuts .table div {
  padding: 0.5em;
}

#keyboardShortcuts .key {
  background-color: ghostwhite;
  border: thin solid lightgrey;
  border-radius: 5px;
  padding: 0.3em;
  font-family: monospace;
}

#text .gameTitle {
  display: none;
}

@media (max-width: 480px) or (max-height: 640px) {
  .definition{
    display: none;
  }
  
  #headerLinks {
    display: none;
  }

  #title {
    font-size: 1.125em;
  }

  #author {
    display: none;
  }

  #text .gameTitle {
    display: block;
  }

  #advertisement {
    margin: -8px;
  }
  
  .mobileBadges {
    float: none;
  }
  
  #header {
    margin-top: 30px;
  }

  /** Floating images should leave enough room for text */
  #text .alignleft, #text .alignright {
    max-width: 45%;
  }
  
  #identity {
    display: none;
  }
}

@media (max-width: 375px) {
  #achievementsButton {
    display: none;
  }
}.alertify-show,
.alertify-log {
	-webkit-transition: all 500ms cubic-bezier(0.175, 0.885, 0.320, 1); /* older webkit */
	-webkit-transition: all 500ms cubic-bezier(0.175, 0.885, 0.320, 1.275);
	   -moz-transition: all 500ms cubic-bezier(0.175, 0.885, 0.320, 1.275);
	    -ms-transition: all 500ms cubic-bezier(0.175, 0.885, 0.320, 1.275);
	     -o-transition: all 500ms cubic-bezier(0.175, 0.885, 0.320, 1.275);
	        transition: all 500ms cubic-bezier(0.175, 0.885, 0.320, 1.275); /* easeOutBack */
}
.alertify-hide {
	-webkit-transition: all 250ms cubic-bezier(0.600, 0, 0.735, 0.045); /* older webkit */
	-webkit-transition: all 250ms cubic-bezier(0.600, -0.280, 0.735, 0.045);
	   -moz-transition: all 250ms cubic-bezier(0.600, -0.280, 0.735, 0.045);
	    -ms-transition: all 250ms cubic-bezier(0.600, -0.280, 0.735, 0.045);
	     -o-transition: all 250ms cubic-bezier(0.600, -0.280, 0.735, 0.045);
	        transition: all 250ms cubic-bezier(0.600, -0.280, 0.735, 0.045); /* easeInBack */
}
.alertify-cover {
	position: fixed; z-index: 99999;
	top: 0; right: 0; bottom: 0; left: 0;
}
.alertify {
	position: fixed; z-index: 99999;
	top: 50px; left: 50%;
	width: 550px;
	margin-left: -275px;
}
	.alertify-hidden {
		top: -50px;
		visibility: hidden;
	}
.alertify-logs {
	position: fixed;
	z-index: 5000;
	bottom: 10px;
	right: 10px;
	width: 300px;
}
	.alertify-log {
		display: block;
		margin-top: 10px;
		position: relative;
		right: -300px;
	}
	.alertify-log-show {
		right: 0;
	}
	.alertify-dialog {
		padding: 25px;
	}
		.alertify-resetFocus {
			border: 0;
			clip: rect(0 0 0 0);
			height: 1px;
			margin: -1px;
			overflow: hidden;
			padding: 0;
			position: absolute;
			width: 1px;
		}
		.alertify-inner {
			text-align: center;
		}
		.alertify-text {
			margin-bottom: 15px;
			width: 100%;
			-webkit-box-sizing: border-box;
			   -moz-box-sizing: border-box;
			        box-sizing: border-box;
			font-size: 100%;
		}
		.alertify-buttons {
		}
			.alertify-button {
				/* line-height and font-size for input button */
				line-height: 1.5;
				font-size: 100%;
				display: inline-block;
				cursor: pointer;
				margin-left: 5px;
			}

@media only screen and (max-width: 680px) {
	.alertify,
	.alertify-logs {
		width: 90%;
		-webkit-box-sizing: border-box;
		   -moz-box-sizing: border-box;
		        box-sizing: border-box;
	}
	.alertify {
		left: 5%;
		margin: 0;
	}
}
/**
 * Default Look and Feel
 */
.alertify,
.alertify-log {
	font-family: sans-serif;
}
.alertify {
	background: #FFF;
	border: 10px solid #333; /* browsers that don't support rgba */
	border: 10px solid rgba(0,0,0,.7);
	border-radius: 8px;
	box-shadow: 0 3px 3px rgba(0,0,0,.3);
	-webkit-background-clip: padding;     /* Safari 4? Chrome 6? */
	   -moz-background-clip: padding;     /* Firefox 3.6 */
	        background-clip: padding-box; /* Firefox 4, Safari 5, Opera 10, IE 9 */
}
	.alertify-text {
		border: 1px solid #CCC;
		padding: 10px;
		border-radius: 4px;
	}
	.alertify-button {
		border-radius: 4px;
		color: #FFF;
		font-weight: bold;
		padding: 6px 15px;
		text-decoration: none;
		text-shadow: 1px 1px 0 rgba(0,0,0,.5);
		box-shadow: inset 0 1px 0 0 rgba(255,255,255,.5);
		background-image: -webkit-linear-gradient(top, rgba(255,255,255,.3), rgba(255,255,255,0));
		background-image:    -moz-linear-gradient(top, rgba(255,255,255,.3), rgba(255,255,255,0));
		background-image:     -ms-linear-gradient(top, rgba(255,255,255,.3), rgba(255,255,255,0));
		background-image:      -o-linear-gradient(top, rgba(255,255,255,.3), rgba(255,255,255,0));
		background-image:         linear-gradient(top, rgba(255,255,255,.3), rgba(255,255,255,0));
	}
	.alertify-button:hover,
	.alertify-button:focus {
		outline: none;
		box-shadow: 0 0 15px #2B72D5;
		background-image: -webkit-linear-gradient(top, rgba(0,0,0,.1), rgba(0,0,0,0));
		background-image:    -moz-linear-gradient(top, rgba(0,0,0,.1), rgba(0,0,0,0));
		background-image:     -ms-linear-gradient(top, rgba(0,0,0,.1), rgba(0,0,0,0));
		background-image:      -o-linear-gradient(top, rgba(0,0,0,.1), rgba(0,0,0,0));
		background-image:         linear-gradient(top, rgba(0,0,0,.1), rgba(0,0,0,0));
	}
	.alertify-button:active {
		position: relative;
		top: 1px;
	}
		.alertify-button-cancel {
			background-color: #FE1A00;
			border: 1px solid #D83526;
		}
		.alertify-button-ok {
			background-color: #5CB811;
			border: 1px solid #3B7808;
		}
		
.alertify-log {
	background: #1F1F1F;
	background: rgba(0,0,0,.9);
	padding: 15px;
	border-radius: 4px;
	color: #FFF;
	text-shadow: -1px -1px 0 rgba(0,0,0,.5);
}
	.alertify-log-error {
		background: #FE1A00;
		background: rgba(254,26,0,.9);
	}
	.alertify-log-success {
		background: #5CB811;
		background: rgba(92,184,17,.9);
	}</style>