Source: CypherPokerAnalyzer.js

/**
* @file Monitors and analyzes a CypherPoker game (hand) for cryptographic correctness
* and ranks the completed hands of the game to determine the winner.
*
* @version 0.4.1
* @author Patrick Bay
* @copyright MIT License
*/

/**
* @class Monitors and analyzes a CypherPoker game (hand) for cryptographic correctness
* and ranks the completed hands of the game to determine the winner.
*/
class CypherPokerAnalyzer extends EventDispatcher {

   /**
   * The cards captured for an associated game are about to be analyzed.
   *
   * @event CypherPokerAnalyzer#analyzing
   * @type {Event}
   * @property {CypherPokerAnalyzer} analyzer A reference to this instance.
   * @property {CypherPokerAnalyzer#analysis} analysis A reference to the current analysis.
   * property.
   */
   /**
   * The cards captured for an associated game have been fully analyzed. A successful
   * analysis is usually followed by scoring.
   *
   * @event CypherPokerAnalyzer#analyzed
   * @type {Event}
   * @property {CypherPokerAnalyzer} analyzer A reference to this instance.
   * @property {CypherPokerAnalyzer#analysis} analysis A reference to the current analysis.
   * property.
   */
   /**
   * The cards captured for an associated game have been scored and ranked (post analysis).
   *
   * @event CypherPokerAnalyzer#scored
   * @type {Event}
   * @property {CypherPokerAnalyzer} analyzer A reference to this instance.
   * @property {CypherPokerAnalyzer#analysis} analysis A reference to the current analysis.
   * property.
   */

   /**
   * Creates a new instance.
   *
   * @param {CypherPokerGame} game The game instance with which this instance
   * is to be associated. Event listeners are added to the {@link CypherPokerGame}
   * instance at this time so the analyzer should usually be instantiated at the
   * beginning of a new game (hand).
   *
   */
   constructor(game) {
      super();
      this._game = game;
      this.game.addEventListener("gamecardsencrypt", this.onEncryptCards, this);
      this.game.addEventListener("gamedealmsg", this.onGameDealMessage, this);
      this.game.addEventListener("gamedealprivate", this.onSelectCards, this);
      this.game.addEventListener("gamedealpublic", this.onSelectCards, this);
      this.game.addEventListener("gamedeal", this.onCardDeal, this);
      this.game.addEventListener("gamedecrypt", this.onGameDecrypt, this);
      this.game.addEventListener("gameanalyze", this.onGameAnalyze, this);
      this.game.addEventListener("gameplayerkeychain", this.onPlayerKeychain, this);
   }

   /**
   * @property {Boolean} True if the instance is active (tracking game actions),
   * false if not.
   *
   * @readonly
   */
   get active() {
      if (this._active == undefined) {
         this._active = false;
      }
      return (this._active);
   }

   /**
   * @property {Number} keychainCommitTimeout=10000 The amount of time, in milliseconds,
   * to wait at the end of a game for all players' keychains to be comitted before timing out.
   */
   get keychainCommitTimeout() {
      if (this._keychainCommitTimeout == undefined) {
         this._keychainCommitTimeout = 10000;
      }
      return (this._keychainCommitTimeout);
   }

   set keychainCommitTimeout (KRSTSet) {
      this._keychainCommitTimeout = KRSTSet;
   }

   /**
   * @property {Boolean} allKeychainsCommitted True when all players associated
   * with the {@link CypherPokerAnalyzer#game} instance have committed an
   * end-game keychain.
   *
   * @readonly
   */
   get allKeychainsCommitted() {
      if (this._keychains == undefined) {
         this._keychains = new Object();
      }
      var allPlayersCommitted = true;
      var numCommitted = 0;
      for (var count=0; count < this.players.length; count++) {
         var player = this.players[count];
         if (this._keychains[player.privateID] == undefined) {
            this._keychains[player.privateID] = Array.from(player.keychain);
         }
         var keychain = this._keychains[player.privateID];
         if (keychain.length == 0) {
            allPlayersCommitted = false;
            break;
         } else {
            numCommitted++;
         }
      }
      if (allPlayersCommitted) {
         return (true);
      }
      return (false);
   }

   /**
   * @property {Array} communityCards An array of {@link CypherPokerCard}
   * instances of the community cards reported by the final decryptors.
   *
   * @readonly
   */
   get communityCards() {
      if (this._communityCards == undefined) {
         this._communityCards = new Array();
      }
      return (this._communityCards);
   }

   /**
   * @property {Object} privateCards={} An object of named arrays, with each
   * array named using the private ID of the associated player and containing
   * the {@link CypherPokerCard} instances of the decrypted private cards for
   * that player.
   *
   * @readonly
   */
   get privateCards() {
      if (this._privateCards == undefined) {
         this._privateCards = new Object();
      }
      return (this._privateCards);
   }

   /**
   * @property {Array} deck An array of named objects, with each
   * array element storing an object representing a snapshot of the
   * deck generation and encryption processes.
   *
   * @readonly
   */
   get deck() {
      if (this._deck == undefined) {
         this._deck = new Array();
      }
      return (this._deck);
   }

   /**
   * @property {Array} Returns a copy of the mapped deck of the associated
   * {@link CypherPokerAnalyzer#game} instance
   * (the {@link CypherPokerGame#cardDecks}<code>faceup</code> property),
   * or an empty array if none exists.
   *
   * @readonly
   */
   get mappedDeck() {
      if (this._mappedDeck == undefined) {
         this._mappedDeck = new Array();
      }
      return (this._mappedDeck);
   }

   /**
   * @property {Object} deals Contains name/value pairs with each name representing
   * the source (dealing) private ID of the player and the associated value being an
   * array of objects, each containing a <code>fromPID</code> private ID of the sender
   * of the data, a <code>type</code> denoting the type of dealing operation ("select" or "decrypt"),
   * the card values in a <code>cards</code> array, and a <code>private</code> property
   * indicating whether the deal was for private / hole cards or for public / community ones.
   * Each entry is stored in order of operation.
   *
   * @readonly
   */
   get deals() {
      if (this._deals == undefined) {
         this._deals = new Object();
      }
      return (this._deals);
   }

   /**
   * @property {Object} keychains Name/value pairs of player keychains with
   * each name representing a player private ID and associated value being their
   * keychain. The keychain is copied from the associated {@link CypherPokerAnalayzer#game}
   * instance once a game completes.
   *
   * @readonly
   */
   get keychains() {
      if (this._keychains == undefined) {
         this._keychains = new Object();
      }
      return (this._keychains);
   }

   /**
   * @property {CypherPokerGame} game=null The game instance associated with this
   * analyzer, as set at instantiation time.
   *
   * @readonly
   */
   get game() {
      if (this._game == undefined) {
         this._game = null;
      }
      return (this._game);
   }

   /**
   * @property {CypherPoker} cypherpoker=null The {@link CypherPoker} instance
   * associated with {@link CypherPokerAnalyzer#game}.
   *
   * @readonly
   */
   get cypherpoker() {
      if (this.game == null) {
         return (null);
      }
      return (this.game.cypherpoker);
   }

   /**
   * @property {CypherPoker} cypherpoker A reference to the
   * {@link CypherPokerAnalyzer#game}'s <code>cypherpoker</code> instance or
   * <code>null</code> if none exists.
   */
   get cypherpoker() {
      if ((this._game != null) && (this._game != undefined)) {
         return (this._game.cypherpoker);
      }
      return (null);
   }

   /**
   * @property {TableObject} A copy of the table associated with the {@link CypherPokerAnalyzer#game}
   * instance.
   */
   get table() {
      if (this._table == undefined) {
         this._table = this.game.getTable();
      }
      return (this._table);
   }

   /**
   * @property {Array} Indexed list of {CypherPokerPlayer} instances copied
   * from the associated {@link CypherPokerAnalyzer#game} instance.
   */
   get players() {
      if (this._players == undefined) {
         this.refreshPlayers();
      }
      return (this._players);
   }

   /**
   * @property {Object} analysis The partial or full analysis of the
   * completed game.
   * @property {Object} analysis.private Name/value pairs with each name matching
   * a player private ID and value containing an array of their verified private
   * {@link CypherPokerCard} instances.
   * @property {Array} analysis.public Array of verified public {@link CypherPokerCard}
   * instances.
   * @property {Boolean} analysis.complete=false Set to true when the hand has been
   * fully validated as far as possible.
   * @property {Error} analysis.error=null The analysis error object, if one exists.
   *
   * @readonly
   */
   get analysis() {
      if (this._analysis == undefined) {
         this._analysis = new Object();
         this._analysis.private = new Object();
         this._analysis.public = new Array();
         this._analysis.complete = false;
         this._analysis.error = null;
      }
      return (this._analysis);
   }

   /**
   * Refreshes the {@link CypherPokerAnalyzer#players} array with data from
   * the associated {@link CypherPokerAnalyzer#game}.
   *
   * @private
   */
   refreshPlayers() {
      this._players = new Array();
      for (var count=0; count < this.game.players.length; count++) {
         this._players.push(this.game.players[count].copy());
      }
   }

   /**
   * Returns a {@link CypherPokerPlayer} instance associated with the analyzer's
   * game instance.
   *
   * @param {String} privateID The private ID of the player to return.
   *
   * @return {CypherPokerPlayer} The {@link CypherPokerPlayer} for the private ID
   * associated with this instance. <code>null</code> is returned if no matching
   * player private ID can be found.
   */
   getPlayer(privateID) {
      for (var count=0; count < this.players.length; count++) {
         if (this.players[count].privateID == privateID) {
            return (this.players[count]);
         }
      }
      return (null);
   }

   /**
   * Returns a condensed array containing the copied properties of the
   * {@link CypherPokerAnalyzer#players} array. Use the object returned by
   * this function with <code>JSON.stringify</code> instead of using
   * {@link CypherPokerAnalyzer#players} directly in order to prevent circular
   * reference errors.
   *
   * @param {Boolean} [includeKeychains=false] If true, the {@link CypherPokerPlayer#keychain}
   * array of each player will be included in the returned object.
   * @param {Boolean} [includePasswords=false] If true, the {@link CypherPokerAccount#password}
   * property of each {@link CypherPokerPlayer#account} reference will be included
   * with the returned object.
   *
   * @return {Object} The condensed players array associated with this instance's
   * game reference.
   */
   getPlayers(includeKeychains=false, includePasswords=false) {
      var returnArr = new Array();
      for (var count=0; count < this.players.length; count++) {
         var playerObj = this.players[count].toObject(includeKeychains, includePasswords);
         returnArr.push(playerObj);
      }
      return (returnArr);
   }

   /**
   * Returns the {@link CypherPokerPlayer} that is currently flagged as the dealer
   * in the {@link CypherPokerAnalyzer#players} array.
   *
   * @return {CypherPokerPlayer} The {@link CypherPokerPlayer} instance that
   * is flagged as a dealer. <code>null</code> is returned if no dealer is flagged.
   */
   getDealer() {
      for (var count=0; count < this.players.length; count++) {
         if (this.players[count].isDealer) {
            return (this.players[count]);
         }
      }
      return (null);
   }

   /**
   * Removes all of the event listeners added to the {@link CypherPokerAnalyzer.game}
   * reference at instantiation.
   *
   * @private
   */
   removeGameListeners() {
      this.game.removeEventListener("gamecardsencrypt", this.onEncryptCards, this);
      this.game.removeEventListener("gamedealmsg", this.onGameDealMessage, this);
      this.game.removeEventListener("gamedealprivate", this.onSelectCards, this);
      this.game.removeEventListener("gamedealpublic", this.onSelectCards, this);
      this.game.removeEventListener("gamedeal", this.onCardDeal, this);
      this.game.removeEventListener("gamedecrypt", this.onGameDecrypt, this);
      this.game.removeEventListener("gameanalyze", this.onGameAnalyze, this);
      //"gameplayerkeychain" is removed separately, once all keychains are received
   }

   /**
   * Event handler invoked when the associated {@link CypherPokerAnalyzer#game}
   * instance dispatches a {@link CypherPoker#event:gamecardsencrypt} event.
   *
   * @param {Event} event A {@link CypherPoker#event:gameplayerkeychain} event object.
   *
   * @async
   * @private
   */
   async onEncryptCards(event) {
      var temp = this.table;
      this._active = true;
      var infoObj = new Object();
      if (this.deck.length == 0) {
         //store current face-up deck as generated by dealer
         var generatedDeck = event.game.cardDecks.faceup;
         var cardsArr = new Array();
         for (var count=0; count < generatedDeck.length; count++) {
            cardsArr.push(generatedDeck[count].mapping);
            this.mappedDeck.push(this.game.getMappedCard(generatedDeck[count].mapping));
         }
         infoObj.fromPID = this.getDealer().privateID;
         infoObj.cards = cardsArr;
         this.deck.push (infoObj);
      }
      infoObj = new Object();
      infoObj.fromPID = event.player.privateID;
      infoObj.cards = Array.from(event.selected);
      this.deck.push (infoObj);
   }

   /**
   * Returns a reference to a {@link CypherPokerCard} based on its mapping.
   *
   * @param {String} mapping The plaintext or face-up card mapping value to
   * find.
   *
   * @return {CypherPokerCard} The matching card instance or <code>null</code>
   * if none exists.
   */
   getMappedCard(mapping) {
      for (var count=0; count < this.mappedDeck.length; count++) {
         if (this.mappedDeck[count].mapping == mapping) {
            return (this.mappedDeck[count]);
         }
      }
      return (null);
   }

   /**
   * Event handler invoked when the associated {@link CypherPokerAnalyzer#game}
   * instance dispatches a {@link CypherPoker#event:gamedecrypt} event.
   *
   * @param {Event} event A {@link CypherPoker#event:gamedecrypt} event object.
   *
   * @async
   * @private
   */
   async onGameDecrypt(event) {
      this._active = true;
      //we have partially decrypted some cards
      if (event.private) {
         this.storeDeal(event.payload.sourcePID, this.game.ownPID, event.selected, true, true);
      } else {
         this.storeDeal(event.payload.sourcePID, this.game.ownPID, event.selected, false, true);
      }
   }

   /**
   * Event handler invoked when the associated {@link CypherPokerAnalyzer#game}
   * instance dispatches a {@link CypherPoker#event:gamedeal} event.
   *
   * @param {Event} event A {@link CypherPoker#event:gamedeal} event object.
   *
   * @async
   * @private
   */
   async onCardDeal(event) {
      this._active = true;
      var cards = event.cards;
      if (event.private == false) {
         //new community cards have been dealt
         for (var count=0; count < cards.length; count++) {
            this.communityCards.push(cards[count]);
         }
      } else {
         //new private cards have been dealt
         this.privateCards[event.game.ownPID]=new Array();
         for (var count=0; count < cards.length; count++) {
            this.privateCards[event.game.ownPID].push(cards[count]);
         }
      }
   }

   /**
   * Event handler invoked when the associated {@link CypherPokerAnalyzer#game}
   * instance dispatches either a {@link CypherPoker#event:gamedealprivate} or
   * {@link CypherPoker#event:gamedealpublic} event.
   *
   * @param {Event} event A {@link CypherPoker#event:gamedealprivate} or
   * {@link CypherPoker#event:gamedealpublic} event object.
   *
   * @async
   * @private
   */
   async onSelectCards(event) {
      this._active = true;
      var selected = event.selected;
      if (event.type == "gamedealprivate") {
         //private card selection
         this.storeDeal(this.game.ownPID, this.game.ownPID, selected, true, false);
      } else {
         //we have selected a private card
         this.storeDeal(this.game.ownPID, this.game.ownPID, selected, false, false);
      }
   }

   /**
   * Event handler invoked when the associated {@link CypherPokerAnalyzer#game}
   * instance dispatches a {@link CypherPoker#event:gamedealmsg} event.
   *
   * @param {Event} event A {@link CypherPoker#event:gamedealmsg} event object.
   *
   * @async
   * @private
   */
   async onGameDealMessage(event) {
      this._active = true;
      var resultObj = event.data.result;
      if ((resultObj.data.payload.cards != undefined) && (resultObj.data.payload.cards != null)) {
         var cardsArr = resultObj.data.payload.cards;
         if (typeof(cardsArr.length) == "number") {
            //this message contains fully decrypted community cards
            //and is handled in "onCardDeal"
            return(true);
         }
      }
      //partially decrypted public or private cards:
      var selected = resultObj.data.payload.selected;
      //the player that dealt (selected) the cards:
      var dealingPlayer = this.getPlayer(resultObj.data.payload.sourcePID);
      //the player that sent the "gamedeal" message:
      var fromPlayer = this.getPlayer(resultObj.from);
      //the selected card values:
      if (dealingPlayer.privateID == fromPlayer.privateID) {
         //player is selecting a card
         if (resultObj.data.payload.private) {
            //private card selection
            this.storeDeal(dealingPlayer.privateID, fromPlayer.privateID, selected, true, false);
         } else {
            //public card selection
            this.storeDeal(dealingPlayer.privateID, fromPlayer.privateID, selected, false, false);
         }
      } else {
         //player has decrypted card(s)
         if (resultObj.data.payload.private) {
            //private cards
            this.storeDeal(dealingPlayer.privateID, fromPlayer.privateID, selected, true, true);
         } else {
            //public card(s)
            this.storeDeal(dealingPlayer.privateID, fromPlayer.privateID, selected, false, true);
         }
      }
      return(true);
   }

   /**
   * Event handler invoked when the associated {@link CypherPokerAnalyzer#game}
   * instance dispatches a {@link CypherPokerGame#event:gameanalyze} event.
   *
   * @param {Event} event A {@link CypherPokerGame#event:gameanalyze} event object.
   *
   * @async
   * @private
   */
   async onGameAnalyze(event) {
      this.refreshPlayers(); //refresh the players array
      if ((this._keychains == null) || (this._keychains == undefined)) {
         this._keychains = new Object();
      }
      this._keychains[this.game.ownPID] = Array.from(this.getPlayer(this.game.ownPID).keychain);
      this.removeGameListeners();
      this._keychainCommitTimeout = setTimeout(this.onKCSTimeout, this.keychainCommitTimeout, this, event.game);
      return (true);
   }

   /**
   * Called when the keychain submission timer elapses and not all players have
   * committed their keychains.
   *
   * @param {CypherPokerAnalayzer} context The execution context of the instance.
   * @param {CypherPokerGame} game The game instance for which the timeout
   * occurred.
   * @private
   */
   onKCSTimeout(context, game) {
      context.analysis.complete = true;
      throw (new Error("Not all players have committed their keychains in time (table ID: "+context.table.tableID+")"));
   }

   /**
   * Event handler invoked when the associated {@link CypherPokerAnalyzer#game}
   * instance dispatches a {@link CypherPoker#event:gameplayerkeychain} event.
   *
   * @param {Event} event A {@link CypherPoker#event:gameplayerkeychain} event object.
   *
   * @async
   * @private
   */
   async onPlayerKeychain(event) {
      this._active = true;
      if (this._keychains == undefined) {
         this._keychains = new Object();
      }
      var player = event.player;
      var game = event.game;
      this._keychains[player.privateID] = Array.from(event.keychain);
      if (this.allKeychainsCommitted) {
         this.game.removeEventListener("gameplayerkeychain", this.onPlayerKeychain, this);
         //all keychains committed, we can clear the timeout and start the analysis
         clearTimeout(this._keychainCommitTimeout);
         event = new Event("analyzing");
         event.analyzer = this;
         event.analysis = this.analysis;
         this.dispatchEvent(event);
         try {
            this._analysis = await this.analyzeCards();
         } catch (err) {
            console.error(err);
            alert ("Post-game analysis failed. Awaiting contract confirmation...");
            return (false);
         }
         event = new Event("analyzed");
         event.analyzer = this;
         event.analysis = this.analysis;
         this.dispatchEvent(event);
         this._analysis = await this.scoreHands(this._analysis);
         this._analysis.complete = true;
         this._analysis.error = null;
         event = new Event("scored");
         event.analyzer = this;
         event.analysis = this.analysis;
         this.dispatchEvent(event);
         this.game.debug ("Final hand/game analysis:");
         this.game.debug (this.analysis, "dir");
         this.game.debug (this.deals, "dir");
         this._active = false;
      }
      return (true);
   }

   /**
   * Analyzes the stored information for cryptographic correctness and returns
   * the verified, decrypted cards (as {@link CypherPokerCard} instances),
   * for each player along with the public / community cards. This function should
   * only be called when the game has completed and all keychains received.
   *
   * @return {Promise} The promise resolves with an object containing a <code>players</code>
   * object containing name/value pairs with each name matching a player private ID and containing
   * an array of {@link CypherPokerCard} instances, and a <code>public</code>
   * property containing an array of the public / community {@link CypherPokerCard} instances.
   * If the analysis fails it is rejected with an <code>Error</code> which includes a
   * <code>message</code> and numeric <code>code</code> identifying the analysis failure.
   *
   * @async
   * @private
   */
   async analyzeCards() {
      //step 1: analyze the full deck (creation & encryption)
      if (this.deck.length == 0) {
         return (null);
      }
      //todo: check to ensure that all values are quadratic residues
      var cardsObj = new Object();
      cardsObj.private = new Object();
      cardsObj.public = new Array();
      var faceUpMappings = Array.from(this.deck[0].cards); //generated plaintext (quadratic residues) values
      var previousDeck = Array.from(faceUpMappings);
      for (var count = 1; count < this.deck.length; count++) {
         var currentDeck = Array.from(this.deck[count].cards);
         var keychain = this.keychains[this.deck[count].fromPID];
         var promises = new Array();
         for (var count2=0; count2 < previousDeck.length; count2++) {
            promises.push(this.cypherpoker.crypto.invoke("encrypt", {value:previousDeck[count2], keypair:keychain[keychain.length-1]}));
         }
         var promiseResults = await Promise.all(promises);
         var compareDeck = new Array();
         for (count2 = 0; count2 < promiseResults.length; count2++) {
            compareDeck.push(promiseResults[count2].data.result);
         }
         if (this.compareDecks(currentDeck, compareDeck) == false) {
            var error = new Error("Deck encryption at stage "+count+" by \""+this.deck[count].fromPID+"\" failed.");
            error.code = 1;
            this._analysis.error = error;
            this._analysis.complete = true;
            throw (error);
         }
         previousDeck = currentDeck;
      }
      //previousDeck should now contain the fully encrypted deck
      var encryptedDeck = previousDeck;
      //step 1: passed
      //step 2: analyze private / public card selections and decryptions
      for (var privateID in this.deals) {
         var dealArray = this.deals[privateID];
         var decrypting = false; //currently decrypting cards?
         var previousType = "select"; //should match dealArray[0].type
         for (count = 0; count < dealArray.length; count++) {
            var currentDeal = dealArray[count];
            if ((currentDeal == undefined) || (currentDeal == null)) {
               //not a deal history object
               break;
            }
            if (count > 0) {
               var previousDeal = dealArray[count-1];
               var previousCards = previousDeal.cards;
               var previousPID = previousDeal.fromPID;
               var previousPrivate = previousDeal.private;
               previousType = previousDeal.type;
            }
            var sourcePID = privateID; //card dealer / selector
            var fromPID = currentDeal.fromPID; //private ID of "cards" (result) sender
            var type = currentDeal.type; //"select" or "decrypt"
            var privateDeal = currentDeal.private; //private / hole cards?
            var cards = currentDeal.cards; //numeric card value strings, encrypted or plaintext;
            if (cardsObj.private[sourcePID] == undefined) {
               cardsObj.private[sourcePID] = new Array();
            };
            if ((previousType == "select") && (type == "select")) {
               //probably the first entry but...
               if (count > 0) {
                  var error = new Error("Multiple sequential \"select\" sequences in deal.");
                  error.code = 2;
                  this._analysis.error = error;
                  this._analysis.complete = true;
                  throw (error);
               }
               if (this.removeFromDeck(cards, encryptedDeck) == false) {
                  var error = new Error("Duplicates found in \"select\" deal index "+count+" for \""+fromPID+"\" for \""+sourcePID+"\".");
                  error.code = 2;
                  this._analysis.error = error;
                  this._analysis.complete = true;
                  throw (error);
               }
            } else if ((previousType == "select") && (type == "decrypt") && (count < (dealArray.length - 1))) {
               //starting a new decryption operation (deal or select cards)
               decrypting = true;
            } else if ((previousType == "decrypt") && (type == "select")) {
               //ending decryption operation (final decryption outstanding)
               keychain = this.keychains[sourcePID];
               promises = new Array();
               promiseResults = new Array();
               for (count2=0; count2 < previousCards.length; count2++) {
                  promises.push(this.cypherpoker.crypto.invoke("decrypt", {value:previousCards[count2], keypair:keychain[keychain.length-1]}));
               }
               promiseResults = await Promise.all(promises);
               var dealtCards = new Array();
               for (count2 = 0; count2 < promiseResults.length; count2++) {
                  var card = this.getMappedCard(promiseResults[count2].data.result);
                  if (card == null) {
                     var error = new Error("Final decryption (deal "+count+") by \""+this.getPlayer(fromPID).account.address+"\" for \""+this.getPlayer(sourcePID).account.address+"\" does not map: "+promiseResults[count2].data.result);
                     error.code = 2;
                     this._analysis.error = error;
                     this._analysis.complete = true;
                     throw (error);
                  }
                  if (previousPrivate) {
                     cardsObj.private[sourcePID].push(card);
                  } else {
                     cardsObj.public.push(card);
                  }
               }
               if (this.removeFromDeck(cards, encryptedDeck) == false) {
                  var error = new Error("Duplicates found in \"select\" deal index "+count+" for \""+this.getPlayer(fromPID).account.address+"\" for \""+this.getPlayer(sourcePID).account.address+"\".");
                  error.code = 2;
                  this._analysis.error = error;
                  this._analysis.complete = true;
                  throw (error);
               }
            } else {
               //decryption in progress
               if (count == (dealArray.length - 1)) {
                  //final decryption for source
                  keychain = this.keychains[sourcePID];
                  promises = new Array();
                  promiseResults = new Array();
                  for (count2=0; count2 < cards.length; count2++) {
                     promises.push(this.cypherpoker.crypto.invoke("decrypt", {value:cards[count2], keypair:keychain[keychain.length-1]}));
                  }
                  promiseResults = await Promise.all(promises);
                  for (count2 = 0; count2 < promiseResults.length; count2++) {
                     var card = this.getMappedCard(promiseResults[count2].data.result);
                     if (card == null) {
                        var error = new Error("Final decryption (deal "+count+") by \""+fromPID+"\" does not map: "+promiseResults[count2].data.result);
                        error.code = 2;
                        this._analysis.error = error;
                        this._analysis.complete = true;
                        throw (error);
                     }
                     if (privateDeal) {
                        cardsObj.private[sourcePID].push(card);
                     } else {
                        cardsObj.public.push(card);
                     }
                  }
               } else {
                  //continuing decryption from another player
                  keychain = this.keychains[fromPID];
                  compareDeck = new Array();
                  promises = new Array();
                  promiseResults = new Array();
                  //decrypt current cards to compare to what was sent by current player...
                  for (count2=0; count2 < previousCards.length; count2++) {
                     promises.push(this.cypherpoker.crypto.invoke("decrypt", {value:previousCards[count2], keypair:keychain[keychain.length-1]}));
                  }
                  promiseResults = await Promise.all(promises);
                  for (count2 = 0; count2 < promiseResults.length; count2++) {
                     compareDeck.push(promiseResults[count2].data.result);
                  }
                  if (this.compareDecks(compareDeck, cards) == false) {
                     var error = new Error("Previous round ("+count+") of decryption by \""+this.getPlayer(fromPID).account.address+"\" for \""+this.getPlayer(sourcePID).account.address+"\" does not match computed results.");
                     error.code = 2;
                     this._analysis.error = error;
                     this._analysis.complete = true;
                     throw (error);
                  }
               }
            }
         }
      }
      return (cardsObj);
   }

   /**
   * Returns all of the available, unordered 5-hand permutations for a set of supplied cards.
   *
   * @param {Array} cardsArr Values or {@link CypherPokerCard} instance for
   * which to produce permutatuions, up to a maximum of 7 elements.
   *
   * @return {Array} Each array element contains a unique 5-card permutation
   * from the input set. If there are less than 6 cards provided, only one
   * permutation is returned.
   *
   * @private
   */
   createCardPermutations(cardsArr) {
      var permArray = new Array();
      if (cardsArr.length <= 5) {
         //only one hand permutation available
         permArray.push (cardsArr);
      } else if (cardsArr.length == 6) {
         //only private card 2 (index 1)
         permArray.push ([cardsArr[1], cardsArr[2], cardsArr[3], cardsArr[4], cardsArr[5]]);
         //only private card 1 (index 0)
         permArray.push ([cardsArr[0], cardsArr[2], cardsArr[3], cardsArr[4], cardsArr[5]]);
         //both private cards
         permArray.push ([cardsArr[1], cardsArr[0], cardsArr[3], cardsArr[4], cardsArr[5]]);
         permArray.push ([cardsArr[1], cardsArr[2], cardsArr[0], cardsArr[4], cardsArr[5]]);
         permArray.push ([cardsArr[1], cardsArr[2], cardsArr[3], cardsArr[0], cardsArr[5]]);
         permArray.push ([cardsArr[1], cardsArr[2], cardsArr[3], cardsArr[4], cardsArr[0]]);
      } else {
         //no private cards
         permArray.push ([cardsArr[2], cardsArr[3], cardsArr[4], cardsArr[5], cardsArr[6]]);
         //private card 1 (index 0)
         permArray.push ([cardsArr[0], cardsArr[3], cardsArr[4], cardsArr[5], cardsArr[6]]);
         permArray.push ([cardsArr[2], cardsArr[0], cardsArr[4], cardsArr[5], cardsArr[6]]);
         permArray.push ([cardsArr[2], cardsArr[3], cardsArr[0], cardsArr[5], cardsArr[6]]);
         permArray.push ([cardsArr[2], cardsArr[3], cardsArr[4], cardsArr[0], cardsArr[6]]);
         permArray.push ([cardsArr[2], cardsArr[3], cardsArr[4], cardsArr[5], cardsArr[0]]);
         //private card 2 (index 1)
         permArray.push ([cardsArr[1], cardsArr[3], cardsArr[4], cardsArr[5], cardsArr[6]]);
         permArray.push ([cardsArr[2], cardsArr[1], cardsArr[4], cardsArr[5], cardsArr[6]]);
         permArray.push ([cardsArr[2], cardsArr[3], cardsArr[1], cardsArr[5], cardsArr[6]]);
         permArray.push ([cardsArr[2], cardsArr[3], cardsArr[4], cardsArr[1], cardsArr[6]]);
         permArray.push ([cardsArr[2], cardsArr[3], cardsArr[4], cardsArr[5], cardsArr[1]]);
         //both private cards
         permArray.push ([cardsArr[0], cardsArr[1], cardsArr[4], cardsArr[5], cardsArr[6]]);
         permArray.push ([cardsArr[0], cardsArr[3], cardsArr[1], cardsArr[5], cardsArr[6]]);
         permArray.push ([cardsArr[0], cardsArr[3], cardsArr[4], cardsArr[1], cardsArr[6]]);
         permArray.push ([cardsArr[0], cardsArr[3], cardsArr[4], cardsArr[5], cardsArr[1]]);
         permArray.push ([cardsArr[2], cardsArr[0], cardsArr[1], cardsArr[5], cardsArr[6]]);
         permArray.push ([cardsArr[2], cardsArr[0], cardsArr[4], cardsArr[1], cardsArr[6]]);
         permArray.push ([cardsArr[2], cardsArr[0], cardsArr[4], cardsArr[5], cardsArr[1]]);
         permArray.push ([cardsArr[2], cardsArr[3], cardsArr[0], cardsArr[1], cardsArr[6]]);
         permArray.push ([cardsArr[2], cardsArr[3], cardsArr[0], cardsArr[5], cardsArr[1]]);
         permArray.push ([cardsArr[2], cardsArr[3], cardsArr[4], cardsArr[0], cardsArr[1]]);
      }
      return (permArray);
   }

   /**
   * Generates player card permutations for analysis and scores the
   * hands.
   *
   * @param {Object} cardsObj A player card object matching the format of the
   * {@link CypherPokerAnalyzer#analysis} object.
   *
   * @private
   */
   async scoreHands(cardsObj) {
      var playersObj = cardsObj.private;
      cardsObj.hands = new Object();
      var playerHands = new Object();
      var highestScore = -1;
      var highestHand = new Array;
      var winningPlayers = new Array();
      var winningHands = new Array();
      for (var privateID in playersObj) {
         var player = this.getPlayer(privateID);
         //private ID may actually be some other object property
         if (player != null) {
            if (player.hasFolded == false) {
               var fullCards = playersObj[privateID].concat(cardsObj.public);
               cardsObj.hands[privateID] = new Array();
               var perms = this.createCardPermutations(fullCards);
               for (var count = 0; count < perms.length; count++) {
                  var handObj = new Object();
                  handObj.hand = perms[count];
                  handObj.score = -1; //default (not scored)
                  this.scoreHand (handObj);
                  cardsObj.hands[privateID].push(handObj);
                  if (handObj.score == highestScore) {
                     //this may be a split pot; see below
                     winningPlayers.push(player);
                     winningHands.push(handObj);
                  } else if (handObj.score > highestScore) {
                     //new best hand
                     winningPlayers = new Array();
                     winningHands = new Array();
                     winningPlayers.push(player);
                     winningHands.push(handObj);
                     highestScore = handObj.score;
                  }
               }
            }
         }
      }
      if (winningPlayers.length > 1) {
         //need to look at both private cards since we currently have a potential split pot
         var newWinningPlayers = new Array();
         var newWinningHands = new Array();
         var highestScore = 0;
         for (count = 0; count < winningPlayers.length; count++) {
            var winningHand = winningHands[count]; //indexes match with winningPlayers
            var hand = winningHand.hand;
            var player = winningPlayers[count];
            var playerPID = player.privateID;
            var privateCard1 = this.analysis.private[playerPID][0];
            var privateCard2 = this.analysis.private[playerPID][1];
            //adjust score for highest card value
            if (privateCard1.highvalue > privateCard2.highvalue) {
               var currentScore = (privateCard1.highvalue * 10) + privateCard2.highvalue;
            } else {
               currentScore = (privateCard2.highvalue * 10) + privateCard1.highvalue;
            }
            winningHand.score = currentScore;
            if (currentScore > highestScore) {
               highestScore = currentScore;
               newWinningPlayers = new Array();
               newWinningHands = new Array();
               newWinningPlayers.push(player);
               newWinningHands.push(winningHand);
            } else if (currentScore == highestScore) {
               //both private card values are the same -- split pot
               newWinningPlayers.push(player);
               newWinningHands.push(winningHand);
            }
         }
         winningPlayers = newWinningPlayers;
         winningHands = newWinningHands;
      }
      //eliminate any duplicates
      newWinningPlayers = new Array();
      newWinningHands = new Array();
      for (var count=0; count < winningPlayers.length; count++) {
         var currentWinnerPID = winningPlayers[count];
         var currentWinningHand = winningHands[count];
         var existingWinnerPID = newWinningPlayers.find(winnerPID => {
            return (winnerPID == currentWinnerPID);
         }, this);
         if (existingWinnerPID == undefined) {
            newWinningPlayers.push(currentWinnerPID);
            newWinningHands.push(currentWinningHand);
         }
      }
      winningPlayers = newWinningPlayers;
      winningHands = newWinningHands;
      cardsObj.winningPlayers = winningPlayers;
      cardsObj.winningHands = winningHands;
      return (cardsObj);
   }

   /**
   * Scores a 5 cards (or fewer) poker hand. The higher the score the
   * better the hand.
   *
   * @param {Object} handObj A hand permutation and score object. This object
   * is updated with the resulting score, hand name, and other information.
   * @param {Array} handObj.hand Array of {@link CypherPokerCard} instances
   * comprising the hand to score.
   * @param {Number} handObj.score=-1 Calculated score of final hand. -1 means
   * that the hand is not scored.
   *
   * @private
   */
   scoreHand(handObj) {
      if ((handObj.hand == undefined) || (handObj.hand == null)) {
         return;
      }
      handObj.score = -1;
      //create groups sorted by suits and values
      var suitGroups = new Object();
      var valueGroups = new Object();
      for (count = 0; count < handObj.hand.length; count++) {
         var currentCard = handObj.hand[count];
         var suit = currentCard.suit;
         var value = currentCard.value;
         if (suitGroups[suit] == undefined) {
            suitGroups[suit] = new Array();
         }
         if (valueGroups[value] == undefined) {
            valueGroups[value] = new Array();
         }
         suitGroups[suit].push(currentCard);
         valueGroups[value].push(currentCard);
      }
      //convert group objects to arrays of arrays
      suitGroups = Object.entries(suitGroups);
      valueGroups = Object.entries(valueGroups);
      var flush = false;
      //evaluate for flush (only 1 suit and 5 cards):
      if ((suitGroups.length == 1) && (handObj.hand.length == 5)) {
         flush = true;
      }
      //evaluate straight:
      var straight = false;
      var royalflush = false;
      var acesHigh = true;
      var valuesArr = new Array();
      var handValue = 0; //the base numeric value of the hand
      var valueMultiplier = 1; //the multiplier applied to handValue to determine the score
      var valueAdjust = 0; //the amount to adjust the hand value in the final calculation
      for (var count = 0; count < handObj.hand.length; count++) {
         valuesArr.push(handObj.hand[count].value);
      };
      var straightVal = this.straightType(valuesArr);
      if ((straightVal == 10) && flush) {
         straight = true;
         royalflush = true;
      } else if (straightVal > 0) {
         straight = true;
      }
      if (royalflush) {
         valueMultiplier = 1000000000;
         handObj.name = "Royal Flush";
      } else if (straight && flush) {
         if (straightVal == 1) {
            //this is a straight starting with an ace
            acesHigh = false;
         }
         valueMultiplier = 100000000;
         handObj.name = "Straight Flush";
      } else if ((valueGroups.length == 2) && (handObj.hand.length >= 5)) {
         if ((valueGroups[0][1].length == 4) || (valueGroups[1][1].length == 4)) {
            valueMultiplier = 10000000;
            for (count = 0; count < valueGroups.length; count++) {
               if (valueGroups[count][1].length != 4) {
                  var cardSum = this.getCardSum(valueGroups[count][1]);
                  //remove multiplied values for cards that are not in the hand
                  //otherwise they can cause significant scoring problems
                  valueAdjust = (cardSum * valueMultiplier * -1) + cardSum;
               }
            }
            handObj.name = "Four of a Kind";
         }
         if ((valueGroups[0][1].length == 3) || (valueGroups[1][1].length == 3)) {
            valueMultiplier = 1000000;
            handObj.name = "Full House";
         }
      } else if (flush && (straight==false)) {
         valueMultiplier = 100000;
         handObj.name = "Flush";
      } else if (straight && (flush == false)) {
         if (straightVal == 1) {
            //this is a straight starting with an ace
            acesHigh = false;
         }
         valueMultiplier = 10000;
         handObj.name = "Straight";
      } else if (valueGroups.length == 3) {
         if ((valueGroups[0][1].length == 3) || (valueGroups[1][1].length == 3) || (valueGroups[2][1].length == 3)) {
            valueMultiplier = 1000;
            for (count = 0; count < valueGroups.length; count++) {
               if (valueGroups[count][1].length != 3) {
                  var cardSum = this.getCardSum(valueGroups[count][1]);
                  valueAdjust = (cardSum * valueMultiplier * -1) + cardSum;
               }
            }
            handObj.name = "Three of a Kind";
         } else {
            valueMultiplier = 100;
            handObj.name = "Two Pairs";
            for (count = 0; count < valueGroups.length; count++) {
               if (valueGroups[count][1].length != 2) {
                  var cardSum = this.getCardSum(valueGroups[count][1]);
                  valueAdjust = (cardSum * valueMultiplier * -1) + cardSum;
               }
            }
         }
      } else if ((valueGroups.length == 4) || (valueGroups.length == 1)) {
         //valueGroups.length == 1 on flop
         valueMultiplier = 15;
         for (count = 0; count < valueGroups.length; count++) {
            if (valueGroups[count][1].length != 2) {
               var cardSum = this.getCardSum(valueGroups[count][1]);
               valueAdjust = (cardSum * valueMultiplier * -1) + cardSum;
            }
         }
         handObj.name = "One Pair";
      } else {
         handObj.name = "High Card";
      }
      if (valueMultiplier > 1) {
         for (count = 0; count < handObj.hand.length; count++) {
            if (acesHigh) {
               handValue += handObj.hand[count].highvalue;
            } else {
               handValue += handObj.hand[count].value;
            }
         }
      } else {
         //high card (secondary scan is required for additional card)
         handValue = 0;
         for (count = 0; count < handObj.hand.length; count++) {
            if (handValue < handObj.hand[count].highvalue) {
               handValue = handObj.hand[count].highvalue;
            }
         }
      }
      handObj.score = (handValue * valueMultiplier) + valueAdjust;
   }

   /**
   * Evaluates an unordered series of card values to determine what type of
   * straight they comprise.
   *
   * @param {Array} cardValues Unordered sequence of card values to analyze.
   *
   * @return {Number} A 0 is returned if the input is not a straight, otherwise
   * the lowest value in the straight is returned (e.g. if <code>cardValues=[4,3,5,6,7]</code>
   * then 3 is returned).
   *
   * @private
   */
   straightType(cardValues) {
      if (cardValues.length < 5) {
         //need 5 cards for a straight
         return (0);
      }
      //check for ace through 9
      for (var count = 1; count < 10; count++) {
         if (this.compareDecks(cardValues, [count, count+1, count+2, count+3, count+4])) {
            return (count);
         }
      }
      //check for high ace with a 10 (is there a more elegant way to do this in the "for" loop?)
      if (this.compareDecks(cardValues, [10,11,12,13,1])) {
         return (10);
      }
      //not a straight
      return (0);
   }

   /**
   *  Sum the {@link CypherPokerCard} instances provided.
   *
   * @param {Array} cards Indexed array of {@link CypherPokerCard} instances to sum.
   * @param {Boolean} [high=true] If true, the card's <code>highvalue</code> is used to
   * calculate the sum otherwise its <code>value</code> is used.
   * @return {Number} The numeric sum of the card values.
   * @private
   */
   getCardSum(cards, high=true) {
      var sum = new Number(0);
      for (var count = 0; count < cards.length; count++) {
         if (high) {
            sum += cards[count].highvalue;
         } else {
            sum += cards[count].value;
         }
      }
      return (sum);
   }

   /**
   * Stores a card deal -- new cards have either been selected or partially
   * decrypted -- to the {@link CypherPokerAnalyzer#deals} array.
   *
   * @param {String} dealingPID The private ID of the dealer of the cards (i.e.
   * the player that selected them).
   * @param {String} fromPID The private ID of the player that last operated
   * on the cards (selected or decrypted them).
   * @param {Array} cards An array of numeric string values representing the
   * selected or partially decrypted cards.
   * @param {Booolean} isPrivate If true, the <code>cards</code> array contains
   * private / hole card values, otherwise they are community / public cards.
   * @param {Boolean} isDecryption If true, the <code>cards</code> array
   * contains partially decrypted values otherwise it contains the initial,
   * fully encrypted selections.
   *
   * @private
   */
   storeDeal(dealingPID, fromPID, cards, isPrivate, isDecryption) {
      if (this.deals[dealingPID] == undefined) {
         this.deals[dealingPID] = new Array();
      }
      var dealObj = this.deals[dealingPID];
      var cardsCopy = Array.from(cards);
      var infoObj = new Object();
      infoObj.fromPID = fromPID;
      if (isDecryption) {
         infoObj.type = "decrypt";
      } else {
         infoObj.type = "select";
      }
      if (isPrivate) {
         infoObj.private = true;
      } else {
         infoObj.private = false;
      }
      infoObj.cards = cardsCopy;
      this.deals[dealingPID].push(infoObj);
   }

   /**
   * Remove a set of items from a deck.
   *
   * @param {Array} removeItems Array of strings matching card values to remove
   * from <code>deckArr</code>
   * @param {Array} deckArr Array of strings matching card values and representing
   * a deck. Items found in <code>removeItems</code> will be removed directly
   * from this array.
   *
   * @return {Boolean} True if the correct number of items were removed from
   * <code>deckArr</code> (i.e. only one unique match for each value existed).
   * False is returned if the removed items don't match the expected set but
   * <code>deckArr</code> may still be modified.
   *
   * @private
   */
   removeFromDeck(removeItems, deckArr) {
      var itemsToRemove = removeItems.length;
      var removedItems = new Array();
      for (var count=0; count < removeItems.length; count++) {
         var count2=0;
         while (count2 < deckArr.length) {
            if (removeItems[count] == deckArr[count2]) {
               removedItems.push(deckArr.splice(count2, 1)[0]);
               //keep going in case there are duplicates
            } else {
               count2++;
            }
         }
      }
      if (removedItems.length == itemsToRemove) {
         return (true);
      }
      return (false);
   }

   /**
   * Compares two card decks of either plaintext mappings or encrypted
   * card values, regardless of their order.
   *
   * @param {Array} deckArr1 First array of numeric strings to compare.
   * @param {Array} deckArr2 Second array of numeric strings to compare.
   *
   * @return {Boolean} True if both decks have exactly the same elements (regardless of order),
   * false if there's a difference.
   *
   * @private
   */
   compareDecks(deck1Arr, deck2Arr) {
      if (deck1Arr.length != deck2Arr.length)  {
         return (false);
      }
      var deck1 = Array.from(deck1Arr);
      var deck2 = Array.from(deck2Arr);
      while (deck1.length > 0) {
         var currentCard = deck1.splice(0, 1);
         var index = 0;
         while (index < deck2.length) {
            var compareCard = deck2[index];
            if (compareCard == currentCard) {
               deck2.splice(index, 1);
               break;
            }
            index++;
         }
      }
      if (deck2.length == 0) {
         //all unique matching elements removed from secondary array (all match)
         return (true);
      }
      return (false);
   }

   /**
   * @private
   */
   toString() {
      //output as much analysis information as possible
      var output = new Object();
      output.deals = this.deals;
      output.communityCards = this.communityCards;
      output.privateCards = this.privateCards;
      output.deck = this.deck;
      output.analysis = this.analysis;
      return (JSON.stringify(output));
   }
}