Source: CypherPoker.js

/**
* @file The main interface to CypherPoker.JS<br/>
* Automates peer-to-peer connectivity and instantiation of the cryptosystem,
* manages accounts and tables, launches games, and provides accesss to other shared
* functionality.
*
* @version 0.4.1
* @author Patrick Bay
* @copyright MIT License
*/

/**
* @class Main CypherPoker.JS lobby, account manager, table maker, and game launcher.
*
* @example
* var settingsObj = {
*    "p2p":{
*      "connectInfo":{
*          "create":"return (new P2PRouter())",
*          "type":"wss",
*          "url":"ws://127.0.0.1:8090"
*      },
*      "transports": {
*         "preferred":["webrtc","wss","ortc"],
*         "quickConnect":true
*       }
*   },
*   "api":{
*      "connectInfo":{
*         "create":"return (new APIRouter())",
*         "type":"wss",
*         "url":"ws://127.0.0.1:8090"
*      }
*   },
*   "crypto":{
*      "create":"return (new SRACrypto(4))",
*      "bitLength": 1024,
*      "radix": 16
*   },
*   "debug":false
* }
* var cypherpoker = new CypherPoker(settingsObj);
*
* @extends EventDispatcher
* @see {@link ConnectivityManager}
* @see {@link SRACrypto}
*/
class CypherPoker extends EventDispatcher {

   /**
   * An object containing properties and references required by CypherPoker.JS that
   * refer to a table or group of peers.
   * @typedef {Object} CypherPoker#TableObject
   * @property {String} ownerPID The private ID of the owner / creator of the table.
   * @property {String} tableID The pseudo-randomly generated, unique table ID of the table.
   * @property {String} tableName The name given to the table by the owner.
   * @property {Array} requiredPID Indexed array of private IDs of peers required to join this room before it's
   * considered full or ready. The wildcard asterisk (<code>"*"</code>) can be used to signify any PID.
   * @property {Array} joinedPID Indexed array of private IDs that have been accepted by the owner, usually in a
   * <code>tablejoin</code> CypherPoker peer-to-peer message. This array should ONLY contain valid
   * private IDs (no wildcards).
   * @property {Array} restorePID Copy of the original private IDs in the <code>requiredPID</code> array
   * used to restore it if members of the <code>joinePID</code> array leave the table.
   * @property {Object} tableInfo Additional information to be included with the table. Use this object rather than
   * a [TableObject]{@link CypherPoker#TableObject} at the root level since it is dynamic (may cause unexpected behaviour).
   */

    //Event definitions:

    /**
    * The instance has successfully started.
    *
    * @event CypherPoker#start
    * @type {Event}
    */
    /**
    * An external peer is announcing a unique new table (within allowable limits).
    *
    * @event CypherPoker#tablenew
    * @type {Event}
    * @property {Object} data The JSON-RPC 2.0 object containing the announcement.
    * @property {Object} data.result The standard JSON-RPC 2.0 notification result object.
    * @property {String} data.result.from The private ID of the notification sender.
    * @property {CypherPoker#TableObject} data.result.data The table associated with the notification.
    */
    /**
    * An external peer has made a successful request to join one of our tables.
    *
    * @event CypherPoker#tablejoinrequest
    * @type {Event}
    * @property {Object} data The JSON-RPC 2.0 object containing the request.
    * @property {String} joined The private ID of the peer that has just joined.
    * @property {Object} data.result The standard JSON-RPC 2.0 notification result object.
    * @property {String} data.result.from The private ID of the request sender.
    * @property {CypherPoker#TableObject} data.result.data The table associated with the notification.
    * @property {CypherPoker#TableObject} table The table object being tracked by us, updated after
    * the request has been processed.
    */
    /**
    * A notification that a new peer (possibly us), is joining another
    * owner's table.
    *
    * @event CypherPoker#tablejoin
    * @type {Event}
    * @property {Object} data The JSON-RPC 2.0 object containing the table update.
    * @property {Object} data.result The standard JSON-RPC 2.0 notification result object.
    * @property {String} data.result.from The private ID of the notification sender.
    * @property {CypherPoker#TableObject} data.result.data The table associated with the notification.
    * @property {CypherPoker#TableObject} table The table object being tracked by us, updated after
    * the request has been processed.
    */
    /**
    * The associated table's required private IDs have all joined and the table
    * is ready (e.g. to start a game)
    *
    * @event CypherPoker#tableready
    * @type {Event}
    * @property {CypherPoker#TableObject} table The table associated with the notification.
    */
    /**
    * A table member is sending a message to other table members.
    *
    * @event CypherPoker#tablemsg
    * @type {Event}
    * @property {Object} data The JSON-RPC 2.0 object containing the message information.
    * @property {Object} data.result The standard JSON-RPC 2.0 notification result object.
    * @property {String} data.result.from The private ID of the message sender.
    * @property {CypherPoker#TableObject} data.result.data The table associated with the message.
    * @property {*} data.result.data.message The message being sent.
    */
    /**
    * An external peer is leaving a table.
    *
    * @event CypherPoker#tableleave
    * @type {Event}
    * @property {Object} data The JSON-RPC 2.0 object containing the table being left.
    * @property {Object} data.result The standard JSON-RPC 2.0 notification result object.
    * @property {String} data.result.from The private ID of the notification sender.
    * @property {CypherPoker#TableObject} data.result.data The table associated with the notification.
    * @property {CypherPoker#TableObject} table The table object being tracked by us, updated after
    * the request has been processed. If the leaving peer was the owner, the table is destroyed
    * and this reference is <code>null</code>.
    */
    /**
    * A join request to another owner's table has timed out without a response.
    *
    * @event CypherPoker#tablejointimeout
    * @type {Object}
    * @property {CypherPoker#TableObject} table The table that has timed out.
    */
    /**
    * A new {@link CypherPokerGame} instance has been created.
    *
    * @event CypherPoker#newgame
    * @type {Object}
    * @property {CypherPokerGame} game The newly created game instance.
    */

   /**
   * Creates a new CypherPoker.JS instance.
   *
   * @param {Object} settingsObject An external settings object specifying startup
   * and initialization options for the instance. This reference is set to the
   * [settingas]{@link CypherPoker#settings} property.
   *
   */
   constructor (settingsObject) {
      super();
      this._apiConnected = false;
      this._p2pConnected = false;
      this._settings = settingsObject;
      this.initialize();
   }

   /**
   * Called to intialize the instance after all settings are created / loaded.
   * Sets the [p2p]{@link CypherPoker#p2p} and [crypto]{@link CypherPoker#crypto} references using settings functions.
   * Also adds an internal listener for <code>message</code> events on [p2p]{@link CypherPoker#p2p},
   * in order to process some of them internally.
   * @private
   */
   initialize() {
      this.debug ("CypherPoker.initialize()");
      //create cryptosystem
      this._crypto = Function(this.settings.crypto.create)();
   }

   /**
   * Creates a <code>console</code>-based output based on the type if the
   * <code>debug</code> property of [settings]{@link CypherPoker#settings} is <code>true</code>.
   *
   * @param {*} msg The message to send to the console output.
   * @param {String} [type="log"] The type of output that the <code>msg</code> should
   * be sent to. Valid values are "log"-send to the standard <code>log</code> output,
   * "err" or "error"-send to the <code>error</code> output, and "dir"-send to the
   * <code>dir</code> (object inspection) output.
   * @private
   */
   debug (msg, type="log") {
      if (this.settings.debug == true) {
         if ((type == "err") || (type == "error")) {
            console.error(msg);
         } else if (type == "dir") {
            console.dir(msg);
         } else {
            console.log(msg);
         }
      }
   }

   /**
   * Starts the instance once all internal and external initialization has been
   * completed. Usually this function can be invoked directly after a new
   * instance is created unless otherwise required.
   *
   * @param {Object} [options=null] Optional startup options that can be used
   * to override default settings, behaviours, and functionality.
   * @param {URLSearchParams} [options.urlParams] Any options that may have
   * been supplied to the application via the URL as parameters (name-value pairs).
   *
   * @fires CypherPoker#start
   * @async
   * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams}
   */
   async start(options=null) {
      this.debug ("CypherPoker.start()");
      //parse startup options, if provided
      if (options != null) {
         try {
            if (options.urlParams != undefined) {
               try {
                  await this.processURLParams(options.urlParams);
               } catch (err) {
                  var errStr = String(err);
                  errStr = errStr.split("\n").join("<br/>");
                  ui.showDialog("There was an error processing URL parameters:<br/>"+errStr);
               }
            }
         } catch (err) {
            //this should not be fatal
            console.warn(err);
         }
      }
      //restore saved accounts
      this.restoreAccounts(this.settings.api.connectInfo.url);
      //start connections
      this._connectivityManager = new ConnectivityManager(this);
      this.connectivityManager.registerListener("message", "p2p", this.handleP2PMessage, this);
      this.connectivityManager.registerListener("close", "api", this.onAPIDisconnect, this);
      var result = await this.connectivityManager.startConnections();
      var event = new Event("start");
      this.dispatchEvent(event);
      return (result);
   }

   /**
   * @param {URLSearchParams} urlParams Options supplied as parsed URL parameters
   * to apply to the [settings]{@link CypherPoker#settings} object.
   *
   * @async
   */
   async processURLParams(urlParams) {
      try {
         if (urlParams.has("p2p.url") == true) {
            this.settings.p2p.connectInfo.url = urlParams.get("p2p.url");
         }
         if (urlParams.has("p2p.type") == true) {
            this.settings.p2p.connectInfo.type = urlParams.get("p2p.type");
         }
         if (urlParams.has("api.url") == true) {
            this.settings.api.connectInfo.url = urlParams.get("api.url");
         }
         if (urlParams.has("api.type") == true) {
            this.settings.api.connectInfo.type = urlParams.get("api.type");
         }
         //SDB will overwrite any other settings specified
         if (urlParams.has("sdb") == true) {
            var sdb = new SDB();
            await sdb.decode(urlParams.get("sdb"));
            for (var count=0; count < sdb.data.length; count++) {
               var entityObj = sdb.data[count];
               if (entityObj.url == undefined) {
                  entityObj.url = entityObj.protocol + "://" +entityObj.host;
               }
               if (entityObj.port != undefined) {
                  entityObj.url += ":" + String(entityObj.port);
               }
               if (entityObj.entity == "api") {
                  this.settings.api.connectInfo = entityObj;
                  this.settings.api.connectInfo.create = "return (new APIRouter())";
               } else if (entityObj.entity == "p2p") {
                  this.settings.p2p.connectInfo = entityObj;
                  this.settings.p2p.connectInfo.create = "return (new P2PRouter())";
               } else {
                  //not currenly supported
               }
            }
         }
      } catch (err) {
         console.error (err);
      }
   }

   /**
   * @property {Object} settings The main settings object for the instance as provided
   * during instatiation time.
   * @readonly
   */
   get settings() {
      return (this._settings);
   }

   /**
   * @property {Object} p2p=null Reference to a peer-to-peer networking interface.
   * This is a direct reference to [ConnectivityManager.p2p]{@link ConnectivityManager#p2p} unless
   * {@link ConnectivityManager} hasn't been instantiated.
   */
   get p2p() {
      if (this.connectivityManager == null) {
         return (null);
      }
      return (this.connectivityManager.p2p);
   }

   set p2p(p2pSet) {
      this.connectivityManager.p2p = p2pSet;
   }

   /**
   * @property {Object} api=null Reference to a networking interface over which RPC API functions
   * are invoked. This is a direct reference to [ConnectivityManager.api]{@link ConnectivityManager#api} unless
   * {@link ConnectivityManager} hasn't been instantiated.
   */
   get api() {
      if (this.connectivityManager == null) {
         return (null);
      }
      return (this.connectivityManager.api);
   }

   set api(apiSet) {
      this.connectivityManager.api = apiSet;
   }

   /**
   * @property {Boolean} apiConnected=false Returns the
   * [ConnectivityManager.apiConnected]{@link ConnectivityManager#apiConnected} value
   * unless {@link ConnectivityManager} hasn't been instantiated yet.
   * @readonly
   */
   get apiConnected() {
      if (this.connectivityManager == null) {
         return (false);
      }
      return (this.connectivityManager.apiConnected);
   }

   /**
   * @property {Boolean} p2pConnected=false Returns the
   * [ConnectivityManager.p2pConnected]{@link ConnectivityManager#p2pConnected} value
   * unless {@link ConnectivityManager} hasn't been instantiated yet.
   * @readonly
   */
   get p2pConnected() {
      if (this.connectivityManager == null) {
         return (false);
      }
      return (this.connectivityManager.p2pConnected);
   }

   /**
   * @property {ConnectivityManager} connectivityManager A reference to the connectivity manager
   * instance used to control the [p2p]{@link CypherPoker#p2p} and [api]{@link CypherPoker#api}
   * instances as well as to provide utility functions for {@link CypherPokerUI}.
   */
   get connectivityManager() {
      if (this._connectivityManager == undefined) {
         this._connectivityManager = null;
      }
      return (this._connectivityManager);
   }

   /**
   * @property {SRACrypto} crypto An interface for asynchronous cryptographic operations.
   * @readonly
   */
   get crypto() {
      return (this._crypto);
   }

   /**
   * @property {Array} accounts Indexed array of {@link CypherPokerAccount} instances
   * managed by this instance.
   */
   get accounts() {
      if (this._accounts == undefined) {
         this._accounts = new Array();
      }
      return (this._accounts);
   }

   /**
   * @property {Boolean} openTables Indicates whether the instance has any owned
   * and open tables (true), or if all owned and open tables are filled (false).
   * @readonly
   */
   get openTables () {
      if (this["_openTables"] == undefined) {
         this._openTables = false;
      }
      return (this._openTables);
   }

   /**
   * @property {Array} joinedTables A current copy of the list of the tables we've joined (owned
   * and others').
   * @readonly
   */
   get joinedTables() {
      if (this._joinedTables == undefined) {
         this._joinedTables = new Array();
      }
      return (Array.from(this._joinedTables));
   }

   /**
   * @property {Array} announcedTables A current copy of the list of tables announced by other owners.
   * @readonly
   */
   get announcedTables() {
      if (this._announcedTables == undefined) {
         this._announcedTables = new Array();
      }
      return (Array.from(this._announcedTables));
   }

   /**
   * @property {Array} games A list of references to {@link CypherPokerGame} instances
   * managed by this instance.
   * @readonly
   */
   get games() {
      if (this._games == undefined) {
         this._games = new Array();
      }
      return (this._games);
   }

   /**
   * @property {Boolean} captureNewTables=false If set to true, the instance begins to immediately
   * capture new table announcements made over the peer-to-peer network. The network
   * does not need to be connected for this setting to be changed.
   */
   set captureNewTables(captureSet) {
      this._newTableCapture = captureSet;
      this.debug("CypherPoker.captureNewTables="+captureSet);
   }

   get captureNewTables() {
      if (typeof(this["_newTableCapture"]) != "boolean") {
         this._newTableCapture = false;
      }
      return (this._newTableCapture);
   }

   /**
   * @property {Number} maxCapturedTables=99 The maximum number of tables that should be
   * captured to the [announcedTables]{@link CypherPoker#announcedTables} array. Once this limit is reached,
   * items are shuffled so that new items always have the smallest index.
   */
   set maxCapturedTables(maxSet) {
      this._maxCapturedTables = maxSet;
      this.debug("CypherPoker.maxCapturedTables="+maxSet);
   }

   get maxCapturedTables() {
      if (isNaN(this["_maxCapturedTables"])) {
         this._maxCapturedTables = 99;
      }
      return (this._maxCapturedTables);
   }

   /**
   * @property {Number} maxCapturesPerPeer=5 The maximum number of tables that should be
   * captured to the [announcedTables]{@link CypherPoker#announcedTables} array per peer. If this many tables
   * currently exist in [announcedTables]{@link CypherPoker#announcedTables} array, new and/or unique announcements
   * by the same peer will be ignored.
   */
   set maxCapturesPerPeer(maxSet) {
      this._maxCapturesPerPeer = maxSet;
   }

   get maxCapturesPerPeer() {
      if (isNaN(this["_maxCapturesPerPeer"])) {
         this._maxCapturesPerPeer = 5;
      }
      return (this._maxCapturesPerPeer);
   }

   /**
   * @property {Number} beaconInterval=5000 The interval, in milliseconds, to activate the
   * internal table announcement beacon per table (owned tables only!)
   */
   set beaconInterval (intervalMS) {
      this._beaconInterval = intervalMS;
      this.debug("CypherPoker.beaconInterval="+intervalMS);
   }

   get beaconInterval() {
      if (typeof(this["_beaconInterval"]) != "number") {
         this._beaconInterval = 5000;
      }
      return (this._beaconInterval);
   }

   /**
   * Invoked by the [ConnectivityManager.api]{@link ConnectivityManager#api} instance when
   * it dispatches a [close]{@link APIRouter#event:close} event when the API connection
   * closes unexpectedly (the server closes the connection).
   *
   * @param {Object} eventObj A "close" event object
   *
   */
   async onAPIDisconnect(eventObj) {
      ui.showDialog("The API server at "+this.settings.api.connectInfo.url+" disconnected unexpectedly.");
      ui.hideDialog(5000);
      this.clearAccounts();
      if (ui.lobbyActive == true) {
         //return to lobby interface
         ui.onLobbyButtonClick("cancel_game", 'lobby');
      } else {
         //just stop any active game advertisements / join requests
         ui.onLobbyButtonClick("cancel_game");
      }

   }

   /**
   * Restores saved accounts from the browser's <code>localStorage</code> for
   * a specific domain or API service and stores them to the
   * [accounts]{@link CypherPoker#accounts} array. Any accounts present
   * in the array are removed.
   *
   * @param {String|Array} domain The domain(s), URL(s), or unique server identifier(s)
   * associated with the accounts to be restored. If a string is supplied only accounts
   * matching the single identitier will be restored. If this parameter is an array,
   * accounts matching any element will be restored. If null is supplied, all
   * accounts will be restored.
   */
   restoreAccounts(domains) {
      var storage = window.localStorage;
      var accountsArr = storage.getItem("accounts");
      this._accounts = new Array();
      this._NDAccounts = new Array(); //non-domain accounts (stored for saving)
      if (accountsArr != null) {
         accountsArr = JSON.parse(accountsArr);
         for (var count=0; count < accountsArr.length; count++) {
            var currentAccountData = accountsArr[count];
            if (currentAccountData.domains == undefined) {
               //upgrade account data to v0.4.1 (some accounts may be miscategorized)
               currentAccountData.domains = new Array();
               if (typeof(domains) == "string") {
                  currentAccountData.domains.push(domains);
               } else {
                  currentAccountData.domains = currentAccountData.domains.concat(domains);
               }
            }
            if (domains != null) {
               var inDomain = currentAccountData.domains.some(element => {
                  if (typeof(domains) == "string") {
                     return (element == domains);
                  } else {
                     return (domains.some(el => {
                        return (el == element);
                     }, this));
                  }
               }, this);
            } else {
               inDomain = true;
            }
            if (inDomain) {
               var newAccount = new CypherPokerAccount(this, currentAccountData);
               this.accounts.push(newAccount);
            } else {
               //track for saveAccounts
               this._NDAccounts.push(currentAccountData);
           }
         }
      }
   }

   /**
   * Clears the [accounts]{@link CypherPoker#accounts} array and any non-domain
   * (inactive) accounts currently in memory. This function does <i>not</i>
   * clear any user interface elements that contain account information.
   */
   clearAccounts() {
      this._accounts = new Array();
      this._NDAccounts = new Array();
   }

   /**
   * Saves the internal [accounts]{@link CypherPoker#accounts} array and
   * any non-domain accounts to the browser's <code>localStorage</code>.
   */
   saveAccounts() {
      var storage = window.localStorage;
      var saveArray = new Array();
      //include domain accounts
      for (var count=0; count < this.accounts.length; count++) {
         saveArray.push(this.accounts[count].toObject(true));
      }
      //include non-domain accounts
      for (count=0; count < this._NDAccounts.length; count++) {
         saveArray.push(this._NDAccounts[count]); //not CypherPokerAccount objects
      }
      storage.setItem("accounts", JSON.stringify(saveArray));
   }

   /**
   * Creates a new cryptocurrency account for use with games.
   *
   * @param {String} type The cryptocurrency type of the new account. Valid
   * values include: "bitcoin"
   * @param {String} password The password to associate with the account.
   * @param {String} [network=null] The network sub-type, if applicable, of
   * the cryptocurrency <code>type</code>. For example, if <code>type</code>
   * is "bitcoin" then <code>network</code> may be "main" or "test3".
   *
   * @return {Promise} The promise resolves with a new {@link CypherPokerAccount}
   * instance or rejects with an <code>Error</code> if a problem occurs.
   */
   async createAccount(type, password, network=null) {
      var account = new CypherPokerAccount(this);
      account.type = type;
      account.network = network;
      account.password = password
      account.domains.push(this.settings.api.connectInfo.url);
      var created = await account.create();
      if (created) {
         this.accounts.push(account);
         this.debug("New account created:");
         this.debug(account, "dir");
         this.saveAccounts();
         return (account);
      } else {
         throw (new Error("Could not create account."));
      }
   }

   /**
   * Creates a new CypherPoker.JS table and optionally begins to advertise it on the
   * available peer-to-peer network. The table is automatically joined and added
   * to the [joinedTables]{@link CypherPoker#joinedTables} array.
   *
   * @param {String} tableName The name of the table to create.
   * @param {Number|Array} players If this is a number it specifies that ANY other players
   * up to this numeric limit may join the table. When this parameter is an array it's assumed
   * to be an indexed list of private IDs to allow to the table
   * (may also be a mix of wildcards / any PIDs: <code>["*", "*", "a4ec890...]</code>).
   * This value does <b>not</b> include self (i.e. only other players).
   * @param {String} [tableID=null] The unique (per peer), table ID to generate the table with.
   * Omitting this parameter or setting it to <code>null</code> causes an ID to be
   * automatically generated.
   * @param {Boolean} [activateBeacon=true] If true, an internal beacon is automatically
   * started at a [beaconInterval]{@link CypherPoker#beaconInterval} interval to advertise the table on the peer-to-peer
   * network. If false, use the [announceTable]{@link CypherPoker#announceTable} function to manually announce the
   * returned table.
   *
   * @return {CypherPoker#TableObject} A newly created CypherPoker.JS table as specified by
   * the parameters.
   */
   createTable(tableName, players, tableInfo=null, tableID=null, activateBeacon=true) {
      if (typeof(tableID) == "string") {
         this.debug("CypherPoker.createTable(\""+tableName+"\", "+players+", "+tableInfo+", \""+tableID+"\", "+activateBeacon+")");
      } else {
         this.debug("CypherPoker.createTable(\""+tableName+"\", "+players+", "+tableInfo+", "+tableID+", "+activateBeacon+")");
      }
      if (!this.p2pConnected) {
         throw(new Error("Peer-to-peer network connection not established."));
      }
      var newTableObj = new Object();
      if (tableID != null) {
         newTableObj.tableID = tableID;
      } else {
         newTableObj.tableID = String(Math.random()).split(".")[1];
      }
      if (this["_joinedTables"] == undefined) {
         this._joinedTables = new Array();
      }
      newTableObj.tableName = tableName;
      newTableObj.ownerPID = this.p2p.privateID;
      newTableObj.requiredPID = new Array();
      newTableObj.joinedPID = new Array();
      newTableObj.joinedPID.push (this.p2p.privateID);
      newTableObj.restorePID = new Array();
      newTableObj.restorePID.push (this.p2p.privateID);
      if (typeof(players) == "number") {
         for (var count=0; count < players; count++) {
            newTableObj.requiredPID.push("*");
            newTableObj.restorePID.push("*");
         }
      } else {
         newTableObj.requiredPID = Array.from(players);
         newTableObj.restorePID = Array.from(players);
      }
      if (tableInfo == null) {
         tableInfo = new Object();
      }
      newTableObj.tableInfo = tableInfo;
      newTableObj.toString = function () {
        return ("[object TableObject]");
      }
      this._joinedTables.push(newTableObj);
      this._openTables = true;
      if (activateBeacon) {
         newTableObj.beaconID = setInterval(this.announceTable, this.beaconInterval, newTableObj, this);
         this.announceTable(newTableObj); //send first announcement right away
      }
      return (newTableObj);
   }

   /**
   * Removes a table from the [joinedTables]{@link CypherPoker#joinedTables} or
   * [announcedTables]{@link CypherPoker#announcedTables} array and stops any
   * announcement beacon associated with it if applicable.
   *
   * @param {CypherPoker#TableObject} tableObj The table to remove and, if applicable, stop
   * announcing.
   * @param {Boolean} [announced=false] If true, the referenced table is removed from the
   * {@link CypherPoker#announcedTables} array otherwise it's removed from the
   * [joinedTables]{@link CypherPoker#joinedTables} array.
   *
   * @return {Boolean} True if the table was successfully removed, false if no such table
   * could be found.
   */
   removeTable(tableObj, announced=false) {
      try {
         clearInterval(tableObj.beaconID);
      } catch (err) {}
      if (announced == false) {
         var removeArr = this._joinedTables;
      } else {
         removeArr = this._announcedTables;
      }
      for (var count=0; count < removeArr.length; count++) {
         var currentTable = removeArr[count];
         if ((currentTable.ownerPID == tableObj.ownerPID) &&
            (currentTable.tableID == tableObj.tableID) &&
            (currentTable.tableName == tableObj.tableName)) {
               removeArr.splice(count, 1);
               return (true);
         }
      }
      return (false);
   }

   /**
   * Removes all tables from the [joinedTables]{@link CypherPoker#joinedTables} and
   * [announcedTables]{@link CypherPoker#announcedTables} arrays. Any table announcements or join
   * requests currently in progress are cancelled.
   *
   * @param {Boolean} [joined=true] If true, all tables in the [joinedTables]{@link CypherPoker#joinedTables}
   * array should be removed.
   * @param {Boolean} [announced=true] If true, all tables in the [announcedTables]{@link CypherPoker#announcedTables}
   * array should be removed.
   */
   removeAllTables(joined=true, announced=true) {
      if (joined) {
         if (this._joinedTables == undefined) {
            this._joinedTables = new Array();
         }
         while (this._joinedTables.length > 0) {
            this.removeTable(this._joinedTables[0], false);
         }
         this._joinedTables = new Array();
      }
      if (announced) {
         if (this._announcedTables == undefined) {
            this._announcedTables = new Array();
         }
         while (this._announcedTables.length > 0) {
            this.removeTable(this._announcedTables[0], true);
         }
         this._announcedTables = new Array();
      }
   }

   /**
   * Announces a table on the currently connected peer-to-peer network. If an
   * associated beacon timer is found, it is automatically stopped when
   * the <code>requiredPID</code> list of the table is empty.
   *
   * @param {CypherPoker#TableObject} tableObj The table to announce. If the table's
   * <code>requiredPID</code> array is empty, the request is rejected.
   * @param {CypherPoker} [context=null] The CypherPoker instance to execute the function
   * in (typically specified as part of a timer). If <code>null</code>, the current
   * <code>this</code> context is assumed.
   */
   announceTable(tableObj, context=null) {
      if (context == null) {
         context = this;
      }
      if (tableObj.requiredPID.length == 0) {
         try {
            clearInterval(tableObj.beaconID);
         } catch (err) {
         } finally {
            return;
         }
      }
      context.debug("CypherPoker.announceTable("+tableObj+")")
      var announceObj = context.buildCPMessage("tablenew");
      context.copyTable(tableObj, announceObj);
      context.p2p.broadcast(announceObj);
   }

   /**
   * Checks whether the supplied argument is a valid CypherPoker.JS table object.
   *
   * @param {TableObject} [tableObj=null] The object to examine.
   *
   * @return {Boolean} True if the supplied object appears to be a valid [TableObject]{@link CypherPoker#TableObject}
   * suitable for use with CypherPoker.JS
   */
   isTableValid(tableObj=null) {
      if (tableObj == null) {
         return (false);
      }
      if ((tableObj["ownerPID"] == undefined) || (tableObj["ownerPID"] == null) || (tableObj["ownerPID"] == "")) {
         return (false);
      }
      if ((tableObj["tableID"] == undefined) || (tableObj["tableID"] == null) || (tableObj["tableID"] == "")) {
         return (false);
      }
      //table name can be an empty string:
      if ((tableObj["tableName"] == undefined) || (tableObj["tableName"] == null)) {
         return (false);
      }
      //don't compare an array to an empty string since this will return true (array's toString is used)
      if ((tableObj["requiredPID"] == undefined) || (tableObj["requiredPID"] == null)) {
         return (false);
      }
      if (typeof(tableObj.requiredPID.length) != "number") {
         return (false);
      }
      if ((tableObj["joinedPID"] == undefined) || (tableObj["joinedPID"] == null)) {
         return (false);
      }
      if (typeof(tableObj.joinedPID.length) != "number") {
         return (false);
      }
      if ((tableObj["restorePID"] == undefined) || (tableObj["restorePID"] == null)) {
         return (false);
      }
      if (typeof(tableObj.restorePID.length) != "number") {
         return (false);
      }
      if ((tableObj["tableInfo"] == undefined) || (tableObj["tableInfo"] == null) || (tableObj["tableInfo"] == "")) {
         return (false);
      }
      return (true);
   }

   /**
   * Evaluates whether a table is ready or not. A table is considered
   * ready if it is a valid [TableObject]{@link CypherPoker#TableObject}, has one or more joined private
   * IDs and no required private IDs.
   *
   * @param {TableObject} tableObj The table to evaluate.
   *
   * @return {Boolean} True if the table is ready.
   */
   isTableReady(tableObj) {
      if (this.isTableValid(tableObj) == false) {
         return (false);
      }
      if ((tableObj.requiredPID.length == 0) && (tableObj.joinedPID.length > 0)) {
         return (true);
      }
      return (false);
   }

   /**
   * Requests to join another owner's table.
   *
   * @property {CypherPoker#TableObject} A CypherPoker.JS table (object) to request to join.
   * @property {Number} [replyTimeout=20000] A time, in milliseconds, to wait for the reply
   * before considering the request as having timed out.
   *
   * @return {Promise} The promise will be resolved if the table was successfully
   * joined otherwise it will be rejected.
   * @throws {Error} A standard Error is thrown if peer to peer networking hasn't been
   * successfully negotiated.
   *
   */
   joinTable(tableObj=null, replyTimeout=20000) {
      this.debug("CypherPoker.joinTable("+tableObj+", "+replyTimeout+")");
      var promise = new Promise((resolve, reject) => {
         if (!this.p2pConnected) {
            throw(new Error("Peer-to-peer network connection not established."));
         }
         if (!this.isTableValid(tableObj)) {
            this.debug ("Not a valid table object.", "err");
            reject (null);
            return;
         }
         var slotAvailable = false
         for (var count=0; count < tableObj.requiredPID.length; count++) {
            var requiredPID = tableObj.requiredPID[count];
            if ((requiredPID == "*") || (requiredPID == this.p2p.privateID)) {
               //we're not allowed to join this table
               slotAvailable = true;
            }
         }
         if (!slotAvailable) {
            this.debug ("Not allowed to join group.", "err");
            reject (null);
            return;
         }
         if (this._joinTableRequests == undefined) {
            this._joinTableRequests = new Array();
         }
         for (count=0; count < this._joinTableRequests.length; count++) {
            var currentRequestTable = this._joinTableRequests[count];
            if ((currentRequestTable[count].ownerPID == tableObj.ownerPID) &&
               (currentRequestTable[count].tableID == tableObj.tableID)) {
               //already a join request active
               reject (null);
               return;
            }
         }
         tableObj.toString = function() {
           return ("[object CypherPoker#TableObject]");
         }
         tableObj._resolve = resolve;
         tableObj._reject = reject;
         this._joinTableRequests.push(tableObj);
         this.sendJoinTableRequest(tableObj);
      });
      return (promise);
   }

   /**
   * Sends a message to the joined peers of a table.
   *
   * @param {CypherPoker#TableObject} tableObj The table to send the message to.
   * @param {*} message The message to send. Cannot be null or undefined.
   *
   * @return {Boolean} True if the message was delivered to the peer-to-peer networking
   * interface, false if there was a problem processing the parameters.
   */
   sendToTable(tableObj, message) {
      if (typeof(message) == "string") {
         this.debug("CypherPoker.sendToTable("+tableObj+", \""+message+"\")");
      } else {
         this.debug("CypherPoker.sendToTable("+tableObj+", "+message+")");
      }
      if (!this.p2pConnected) {
         throw(new Error("Peer-to-peer network connection not established."));
      }
      if (!this.isTableValid(tableObj)) {
         this.debug ("Not a valid table object.", "err");
         return (false);
      }
      if ((message == null) || (message == undefined)) {
         this.debug ("No message to send to table.", "err");
         return (false);
      }
      var tablePIDs = this.createTablePIDList(tableObj.joinedPID, false);
      var tableMessageObj = this.buildCPMessage("tablemsg");
      tableMessageObj.message = message;
      tableMessageObj.tableName = tableObj.tableName;
      tableMessageObj.tableID = tableObj.tableID;
      tableMessageObj.ownerPID = tableObj.ownerPID;
      this.p2p.send(tableMessageObj, tablePIDs);
      return (true);
   }

   /**
   * Sends a "tablejoinrequest" message to a table's owner.
   *
   * @param {CypherPoker#TableObject} tableObj The table of the owner to send a join request to.
   * @property {Number} [replyTimeout=25000] A time, in milliseconds, to wait for the reply
   * before considering the request as having timed out.
   * @async
   * @private
   */
   async sendJoinTableRequest(tableObj, replyTimeout=20000) {
      this.debug("CypherPoker.sendJoinTableRequest("+tableObj+")");
      var joinRequestObj = this.buildCPMessage("tablejoinrequest");
      this.copyTable(tableObj, joinRequestObj);
      try {
         //should these be checked individually?
         var quickConnect = this.settings.p2p.transports.quickConnect;
         var preferredTransport = this.settings.p2p.transports.preferred[0];
      } catch (err) {
         quickConnect = true;
         preferredTransport = "wss";
      }
      try {
         if (quickConnect == true) {
            //non-blocking connection attempt
            this.p2p.connectPeer(tableObj.ownerPID, preferredTransport).catch(err => {
               console.warn(err);
            });
         } else {
            //blocking connection attempt
            var result = await this.p2p.connectPeer(tableObj.ownerPID, preferredTransport);
         }
      } catch (err) {
         console.error(err);
      }
      //connected successfully on required or "any" transport
      this.p2p.send(joinRequestObj, [tableObj.ownerPID]);
      tableObj.joinTimeoutID = setTimeout(this.onJoinTableRequestTimeout, replyTimeout, tableObj, this);
      return (true);
   }

   /**
   * Responds to a timeout on a "tablejoinrequest" message, removing the
   * table from the <code>_joinTableRequests</code> array.
   *
   * @param {CypherPoker#TableObject} tableObj The table reference for which a
   * @param {CypherPoker} context The CypherPoker instance to execute the function
   * in as specified in the calling timer.
   * @fires CypherPoker#tablejointimeout
   * @private
   */
   onJoinTableRequestTimeout(tableObj, context) {
      context.debug("CypherPoker.onJoinTableRequestTimeout("+tableObj+")");
      var requestsArray = context._joinTableRequests;
      for (var count=0; count < requestsArray.length; count++) {
         var requestObj = requestsArray[count];
         if ((tableObj.tableID == requestObj.tableID) &&
            (tableObj.tableName == requestObj.tableName) &&
            (tableObj.ownerPID == requestObj.ownerPID)) {
               requestsArray.splice(count, 1);
               var event = new Event("tablejointimeout");
               event.table = requestObj;
               context.dispatchEvent(event);
               requestObj._reject(null);
               return;
         }
      }
   }

   /**
   * Leaves a table that was joined. This table must be tracked internally
   * by this instance as having been joined.
   *
   * @param {CypherPoker#TableObject} tableObj The table to leave.
   *
   * @return {Boolean} True if the leave notification was delievered to the
   * peer-to-peer networking interface, false if there was a problem
   * verifying the parameter.
   */
   leaveJoinedTable(tableObj) {
      this.debug("CypherPoker.leaveJoinedTable("+tableObj+")");
      if (!this.p2pConnected) {
         throw(new Error("Peer-to-peer network connection not established."));
      }
      if (!this.isTableValid(tableObj)) {
         this.debug ("Not a valid table object.", "err");
         return (false);
      }
      if (this["_joinedTables"] == undefined) {
         this._joinedTables = new Array();
         return (false);
      }
      var joined = false;
      for (var count=0; count < this._joinedTables.length; count++) {
         var currentTable = this._joinedTables[count];
         if ((currentTable.tableID == tableObj.tableID) &&
            (currentTable.tableName == tableObj.tableName) &&
            (currentTable.ownerPID == tableObj.ownerPID)) {
               this.removeTable(tableObj, false);
               joined = true;
               break;
            }
      }
      if (joined == false) {
         return (false);
      }
      var leaveNotificationObj = this.buildCPMessage("tableleave");
      this.copyTable(tableObj, leaveNotificationObj);
      var tablePIDs = this.createTablePIDList(tableObj.joinedPID);
      this.p2p.send(leaveNotificationObj, tablePIDs);
      return (true);
   }

   /**
   * Retrieves a list of tables we've joined using at least one of three search criteria.
   *
   * @param {String} [tableName=null] The name of the table(s) to search for. If null,
   * this parameter is ignored.
   * @param {String} [tableID=null] The ID of the table(s) to search for. If null,
   * this parameter is ignored.
   * @param {String} [ownerPID=null] The private ID of the table(s)' owner to search for. If null,
   * this parameter is ignored.
   *
   * @return {Array} A list of tables currently joined that matches one or more of the
   * search criteria specified in the parameters. If all parameters are null, the whole
   * list of joined tables is returned (same as [joinedTables]{@link CypherPoker#joinedTables}).
   */
   getJoinedTables(tableName=null, tableID=null, ownerPID=null) {
      var returedTables = new Array();
      if (this["_joinedTables"] == undefined) {
         this._joinedTables = new Array();
         return (returedTables);
      }
      if ((tableName == null) && (tableID == null) && (ownerPID == null)) {
         return (Array.from(this._joinedTables));
      }
      var hits = 0;
      for (var count=0; count < this._joinedTables.length; count++) {
         if (tableName != null) {
            if (this._joinedTables[count].tableName == tableName) {
               hits++;
            } else {
               hits-=10;
            }
         }
         if (tableID != null) {
            if (this._joinedTables[count].tableID == tableID) {
               hits++;
            } else {
               hits-=10;
            }
         }
         if (ownerPID != null) {
            if (this._joinedTables[count].ownerPID) {
               hits++;
            } else {
               hits-=10;
            }
         }
         if (hits > 0) {
            returedTables.push(this._joinedTables[count]);
         }
         hits = 0;
      }
      return (returedTables);
   }

   /**
   * Copies the core properties of a source table object to another object. The
   * target object will be identifiable as a [TableObject]{@link CypherPoker#TableObject} after
   * the copy.
   *
   * @param {TableObject} sourceTable The table from which to copy from.
   * @param {Object} targetObject The target object to copy the core properties
   * of <code>sourceTable</code> to.
   */
   copyTable(sourceTable, targetObject) {
      targetObject.tableName = sourceTable.tableName;
      targetObject.tableID = sourceTable.tableID;
      targetObject.ownerPID = sourceTable.ownerPID;
      targetObject.requiredPID = sourceTable.requiredPID;
      targetObject.joinedPID = sourceTable.joinedPID;
      targetObject.restorePID = sourceTable.restorePID;
      targetObject.tableInfo = sourceTable.tableInfo;
      targetObject.toString = function() {
         return ("[object CypherPoker#TableObject]");
      }
   }

   /**
   * Creates a copy of a list of private IDs, omitting the self (<code>this.p2p.privateID</code>).
   *
   * @param {Array} PIDList An array of private IDs to create the return list from.
   *
   * @return {Array} A copy of <code>PIDList</code> excluding the self.
   * @private
   */
   createTablePIDList(PIDList) {
      var returnList = new Array();
      for (var count=0; count < PIDList.length; count++) {
         if (PIDList[count] != this.p2p.privateID) {
            returnList.push(PIDList[count]);
         }
      }
      return (returnList);
   }

   /**
   * Restores a private ID to a table's {@link TableObject#requiredPID} array for
   * a player that has left. This function does <b>not</b> update either the
   * {@link TableObject#joinedPID} or {@link TableObject#restorePID} arrays of the
   * table object.
   *
   * @param {String} privateID The private ID of the player that has left the table.
   * @param {TableObject} tableObj The table from which the player has left.
   *
   * @private
   */
   restoredRequiredPID(privateID, tableObj) {
      var restoredPID = null;
      var restoreIndex = -1;
      var wildcardExists = false;
      for (var count=0; count < tableObj.restorePID.length; count++) {
         if (tableObj.restorePID[count] == "*") {
            wildcardExists = true; //at least one wildcard is present
         }
         if (tableObj.restorePID[count] == privateID) {
            restoredPID = privateID;
            restoreIndex = count;
            break;
         }
      }
      if ((restoredPID == null) && (wildcardExists == true)) {
         //specified PID doesn't exist so it must be a wildcard
         restoredPID = "*";
      }
      if ((restoredPID == null) && (wildcardExists != true)) {
         //trying to restore a PID that doesn't belong to this table!
         return;
      }
      if (restoreIndex < 0) {
         //no target index so just add at the end
         tableObj.requiredPID.push(restoredPID);
         return;
      }
      var requiredPID = new Array();
      var rPIDs = Array.from(tableObj.requiredPID);
      for (var count=0; count < rPIDs.length; count++) {
         if (restoreIndex == count) {
            requiredPID.push(privateID); //add restored PID at original index
            requiredPID.push(rPIDs[count]); //current PID at this index follows
         } else {
            requiredPID.push(rPIDs[count]);
         }
      }
      tableObj.requiredPID = Array.from(rPIDs);
   }

   /**
   * Dispatches a "tableready" event when the associated table is considered
   * ready (see: [isTableReady]{@link CypherPoker#isTableReady}).
   *
   * @param {TableObject} tableObj The table to evaluate and include with the
   * the event if ready.
   *
   * @return {Boolean} True if the table is ready and the event was dispatched.
   * @fires CypherPoker#tableready
   * @private
   */
   dispatchTableReadyEvent(tableObj) {
      if (this.isTableReady(tableObj)) {
         var event = new Event("tableready");
         event.table = tableObj;
         this.dispatchEvent(event);
         return (true);
      }
      return (false);
   }

   /**
   * Returns or clears an arbitrary <i>local-only</i> data storage object associated with a
   * specific table. The returned storage object can be used to store data
   * related to the table that shouldn't, or can't, be included in the actual
   * table object, within peer-to-peer communications, or in API calls (unless
   * explicitly copied).
   *
   * @param {TableObject} tableObj The table object for which to retrieve the
   * data object.
   * @param {Boolean} [useLS=false] If true, the <code>localStorage</code> object
   * will be used to provide more permanent storage that is maintained between sessions.
   * @param {Boolean} [clear=false] If true, the data associated with the table
   * object is cleared.
   *
   * @returns {Object} A local data storage object associated with the specified table.
   * If one doesn't exist, an empty one is created. If <code>clear=true</code>,
   * <code>null</code> is returned.
   */
   localTableStorage(tableObj, clear=false) {
      if (this._LTS == undefined) {
         this._LTS = new Array();
      }
      for (var count=0; count<this._LTS.length; count++) {
         var storageObj = this._LTS[count];
         if ((storageObj.tableID == tableObj.tableID) &&
            (storageObj.tableName == tableObj.tableName) &&
            (storageObj.ownerPID == tableObj.ownerPID)) {
               if (clear) {
                  this._LTS.splice(count, 1);
                  return (null);
               } else {
                  return (storageObj.data);
               }
            }
      }
      storageObj = new Object();
      storageObj.tableID = tableObj.tableID;
      storageObj.tableName = tableObj.tableName;
      storageObj.ownerPID = tableObj.ownerPID;
      storageObj.data = new Object();
      this._LTS.push(storageObj);
      return (storageObj.data);
   }

   /**
   * Attempts to create a new {@link CypherPokerGame} instance from a table object.
   * All required private IDs must already have joined the table prior to calling
   * this function.
   *
   * @param {CypherPoker#TableObject} tableObj The table from which to create a new game.
   * @param {CypherPokerAccount} account The player account to use with this game.
   * @param {Object} [playerInfo=null] Additional information about us to send
   * to other players at the table when they signal that their game is ready.
   *
   * @return {CypherPokerGame} A new game instance associated with the table
   * or <code>null</code> if one couldn't be created.
   * @fires CypherPoker#newgame
   */
   createGame(tableObj, account, playerInfo=null) {
      this.debug("CypherPoker.createGame("+tableObj+")");
      if (this.isTableValid(tableObj) == false) {
         throw (new Error("Not a valid table object."));
      }
      if (tableObj.requiredPID.length > 0) {
         throw (new Error("All required PIDs not yet joined."));
      }
      var newGame = new CypherPokerGame(this, tableObj, playerInfo);
      newGame.getPlayer(newGame.ownPID).account = account;
      var event = new Event("newgame");
      event.game = newGame;
      this.dispatchEvent(event);
      return (newGame);
   }

   /**
   * Removes and optionally destroys a game instance from the internal
   * [games]{@link CypherPoker#games} array.
   *
   * @param {CypherPokerGame} gameRef A reference the tracked game instance to remove.
   * @param {Boolean} [destroy=true] If true, the instance's [destroy]{@link CypherPokerGame#destroy}
   * function is invoked prior to removal.
   */
   removeGame (gameRef, destroy=true) {
      for (var count=0; count<this._games.length; count++) {
         if (this._games[count]==gameRef) {
            if (destroy) {
               this._games[count].destroy();
            }
            this._games[count].splice(count,1);
            return;
         }
      }
   }

   /**
   * Removes and optionally destroys all game instances in the internal
   * [games]{@link CypherPoker#games} array.
   *
   * @param {Boolean} [destroy=true] If true, each instance's [destroy]{@link CypherPokerGame#destroy}
   * function is invoked prior to removal.
   */
   removeAllGames (destroy=true) {
      if (destroy) {
         for (var count=0; count<this._games.length; count++) {
            this._games[count].destroy();
         }
      }
      this._games = new Array();
   }

   /**
   * Handles a peer-to-peer message event dispatched by the communication
   * interface.
   *
   * @param {Event} event A "message" event dispatched by the communication interface.
   * A <code>data</code> property is expected to contain the parsed JSON-RPC 2.0
   * message received.
   * @fires CypherPoker#tablenew
   * @fires CypherPoker#tablejoinrequest
   * @fires CypherPoker#tablejoin
   * @fires CypherPoker#tableready
   * @fires CypherPoker#tablemsg
   * @fires CypherPoker#tableleave
   * @private
   */
   handleP2PMessage(event) {
      if (this.isCPMsgEvent(event) == false) {
         //don't process any further
         return;
      }
      var message = event.data.result.data;
      var messageType = message.cpMsg;
      var ownEvent = new Event(messageType);
      ownEvent.data = event.data;
      this.debug("CypherPoker.handleP2PMessage("+event+") => \""+messageType+"\"");
      switch (messageType) {
         case "tablenew":
            if (this.captureNewTables) {
               if (this.captureTable(event.data.result)) {
                  this.dispatchEvent(ownEvent);
               }
            }
            break;
         case "tablejoinrequest":
            if (!this.openTables) {
               return;
            }
            this._openTables = false;
            var joined = false; //use flag to prevent multiple adds while evaluating all tables
            for (var count = 0; count < this._joinedTables.length; count++) {
               var currentTable = this._joinedTables[count];
               if ((currentTable.ownerPID == this.p2p.privateID) && (joined == false)) {
                  if ((currentTable.tableID == message.tableID) && (currentTable.tableName == message.tableName)) {
                     for (var count2 = 0; count2 < currentTable.requiredPID.length; count2++) {
                        var requiredPID = currentTable.requiredPID[count2];
                        if (((requiredPID == event.data.result.from) || (requiredPID == "*")) && (joined == false)) {
                           currentTable.requiredPID.splice(count2, 1);
                           currentTable.joinedPID.push(event.data.result.from);
                           var  joinResponse = this.buildCPMessage("tablejoin");
                           //changed format in v0.4.1
                           joinResponse.table = new Object();
                           joinResponse.joined = event.data.result.from;
                           this.copyTable(currentTable, joinResponse.table);
                           this.p2p.send(joinResponse, this.createTablePIDList(currentTable.joinedPID, false));
                           ownEvent.joined = event.data.result.from;
                           ownEvent.table = currentTable;
                           this.dispatchEvent(ownEvent);
                           this.dispatchTableReadyEvent(currentTable);
                           joined = true;
                        }
                     }
                  }
               }
               if (currentTable.requiredPID.length > 0) {
                  this._openTables = true;
               }
            }
            break;
         case "tablejoin":
            //message structure changed in v0.4.1
            var joinedPID = message.joined;
            var newTable = new Object();
            if ((this._joinedTables == undefined) || (this._joinedTables == null)) {
               this._joinedTables = new Array();
            }
            this.copyTable(message.table, newTable);
            for (count = 0; count < this._joinedTables.length; count++) {
               currentTable = this._joinedTables[count];
               if ((currentTable.tableID == message.table.tableID) && (currentTable.tableName == message.table.tableName)) {
                  //someone else has joined the owner's table
                  this._joinedTables[count] = newTable;
                  newTable.toString = function() {
                     return ("[object CypherPoker#TableObject]");
                  }
                  ownEvent.table = newTable;
                  try {
                     //should these be checked individually?
                     var quickConnect = this.settings.p2p.transports.quickConnect;
                     var preferredTransport = this.settings.p2p.transports.preferred[0];
                  } catch (err) {
                     quickConnect = true;
                     preferredTransport = "wss";
                  }
                  this.p2p.connectPeer(joinedPID, preferredTransport).then(result => {
                     if (quickConnect == false) {
                        this.dispatchEvent(ownEvent);
                        this.dispatchTableReadyEvent(newTable);
                     }
                  }).catch (err => {
                     //probably doesn't support requested transport -- automatically using fallback (probably WebSocket Sessions)
                     console.warn (err);
                     this.dispatchEvent(ownEvent);
                     this.dispatchTableReadyEvent(newTable);
                  });
                  if (quickConnect == true) {
                     this.dispatchEvent(ownEvent);
                     this.dispatchTableReadyEvent(newTable);
                  }
                  return;
               }
            }
            for (var count = 0; count < this._joinTableRequests.length; count++) {
               var requestObj = this._joinTableRequests[count];
               if ((requestObj.ownerPID == event.data.result.from) &&
                   (requestObj.tableID == message.table.tableID) &&
                   (requestObj.tableName == message.table.tableName)) {
                      //we've just joined the owner's table
                      this._joinTableRequests.splice(count, 1);
                      clearTimeout(requestObj.joinTimeoutID);
                      delete requestObj.joinTimeoutID;
                      newTable.toString = function() {
                        return ("[object CypherPoker#TableObject]");
                      }
                      this._joinedTables.push(newTable);
                      ownEvent.table = newTable;
                      this.dispatchEvent(ownEvent);
                      requestObj._resolve(event);
                      this.dispatchTableReadyEvent(newTable);
                      return;
                   }
            }
            break;
         case "tablemsg":
            this.debug ("\nFrom: "+event.data.result.from);
            this.debug ("Table name / ID: "+event.data.result.data.tableName +" / "+event.data.result.data.tableID);
            this.debug ("Message: "+event.data.result.data.message);
            this.dispatchEvent(ownEvent);
            break;
         case "tableleave":
               var newTable = new Object();
               if ((this._joinedTables == undefined) || (this._joinedTables == null)) {
                  this._joinedTables = new Array();
               }
               this.copyTable(message, newTable);
               for (count = 0; count < this._joinedTables.length; count++) {
                  currentTable = this._joinedTables[count];
                  if ((currentTable.tableID == message.tableID) && (currentTable.tableName == message.tableName)) {
                     if (currentTable.ownerPID == event.data.result.from) {
                        //table owner/creator is leaving; table is no longer valid
                        ownEvent.table = null;
                        this._joinedTables.splice(count, 1);
                        this.dispatchEvent(ownEvent);
                     }
                     for (var count2=0; count2 < currentTable.joinedPID.length; count2++) {
                        if (currentTable.joinedPID[count2] == event.data.result.from) {
                           var leavingPID = currentTable.joinedPID.splice(count2, 1);
                           this.restoredRequiredPID(leavingPID, currentTable);
                           ownEvent.table = currentTable;
                           this.dispatchEvent(ownEvent);
                           return;
                        }
                     }
                  }
               }
               break;
         default:
            //not a recognized CypherPoker.JS message type
            break;
      }
   }

   /**
   * Captures a new table announcement to the [announcedTables]{@link CypherPoker#announcedTables} array if
   * the table is unique and falls within the peer limit [maxCapturesPerPeer]{@link CypherPoker#maxCapturesPerPeer}.
   * When the [announcedTables]{@link CypherPoker#announcedTables} array reaches the
   * [maxCapturedTables]{@link CypherPoker#maxCapturedTables} limit, the last table is
   * <code>pop</code>ped off of the end of the array and the new table is <code>unshift</code>ed into it.
   * In this way the table announcements are always in chronological order of receipt with the smallest index being
   * the newest.
   *
   * @param {Object} tableResult A JSON-RPC 2.0 <code>result</code> object containing
   * a valid [TableObject]{@link CypherPoker#TableObject} in its <code>data</code> property.
   * @return {Boolean} True if the table was succesfully captured, false if it was
   * out of limit(s) or otherwise unqualified.
   * @private
   */
   captureTable(tableResult) {
      if (this._announcedTables == undefined) {
         this._announcedTables = new Array();
      }
      if (this.isTableValid(tableResult.data) == false) {
         return (false);
      }
      var numTables = 0;
      for (var count=0; count < this._announcedTables.length; count++) {
         if (this._announcedTables[count].ownerPID == tableResult.from) {
            numTables++;
            if (this._announcedTables[count].tableID == tableResult.data.tableID) {
               //table previously announced by this peer
               var now = new Date();
               this._announcedTables[count].tableInfo.announcedAt = now.toISOString(); //update announcement timestamp
               return (false);
            }
            if (numTables > this.maxCapturesPerPeer) {
               //too many table announcements from this peer
               return (false);
            }
         }
      }
      var newTable = new Object();
      this.copyTable(tableResult.data, newTable);
      newTable.toString = function() {
         return ("[object CypherPoker#TableObject]");
      }
      now = new Date();
      this.debug("CypherPoker.captureTable("+newTable+") @ "+now.toTimeString());
      newTable.ownerPID = tableResult.from; //make sure only owner can announce their own table
      newTable.tableInfo.announcedAt = now.toISOString(); //set announcement timestamp
      this._announcedTables.unshift(tableResult.data); //add table to the beginning of array
      if (this._announcedTables.length > this.maxCapturedTables) {
         this._announcedTables.pop(); //remove the last table from end of array
      }
      return (true);
   }

   /**
   * Creates a CypherPoker.JS table message. Since the format of this message
   * may change, this is the preferred way to create a message rather than
   * creating your own object.
   *
   * @param {String} messageType The CypherPoker.JS table message type to create.
   *
   * @return {Object} A formatted CypherPoker.JS table message. Additional data
   * can be appended to this object before sending it over a peer-to-peer network.
   * @private
   */
   buildCPMessage(messageType) {
      var messageObj=new Object();
      messageObj.cpMsg = messageType;
      return (messageObj);
   }

   /**
   * Verifies if a supplied object is a valid CypherPoker.JS message.
   *
   * @param {Object} message The object to examine.
   *
   * @return {Boolean} True if the object seems to be a valid CypherPoker.JS message
   * (though it may not be supported).
   * @private
   */
   isCPMessage(message) {
      if ((message["cpMsg"] == undefined) || (message["cpMsg"] == null) || (message["cpMsg"] == "")) {
         //not a CypherPoker.JS message or it's blank (mo message type)
         return (false);
      }
      return (true);
   }

   /**
   * Verifies if a supplied message event object contains a valid CypherPoker.JS message.
   *
   * @param {Event} event The "message" event, as usually dispatched by the
   * peer-to-peer interface, to examine.
   *
   * @return {Boolean} True if the event contains a valid CypherPoker.JS message
   * (though its type may not be supported).
   * @private
   */
   isCPMsgEvent(event) {
      try {
         if (typeof(event["data"]) != "object") {
            //not sure what this is
            return (false);
         }
         if (typeof(event.data["result"]) != "object") {
            //may not be a JSON-RPC message
            return (false);
         }
         if (typeof(event.data.result["data"]) != "object") {
            //not a CypherPoker-formatted message
            return (false);
         }
         return (this.isCPMessage(event.data.result.data));
      } catch (err) {
         return (false);
      }
   }

   /**
   * @private
   */
   toString() {
      return ("[object CypherPoker]")
   }

}