Source: hg/Simulator.js

// Copyright 2018-2019 Campbell Crowley. All rights reserved.
// Author: Campbell Crowley (dev@campbellcrowley.com)
const {Worker} = require('worker_threads');
const HungryGames = require('./HungryGames.js');

/**
 * Wrapper for logging functions that normally reference SubModule.error and
 * similar.
 *
 * @todo Obtain reference to SubModule to be able to remove this.
 * @private
 * @constant
 */
const self = {
  error: function(...args) {
    console.error(`ERR:${('00000' + process.pid).slice(-5)}`, ...args);
  },
};

/**
 * @description Manages HG day simulation.
 * @memberof HungryGames
 * @inner
 */
class Simulator {
  /**
   * @description Create a simulator instance.
   * @param {HungryGames~GuildGame} game The GuildGame to simulate.
   * @param {HungryGames} hg Parent game manager for logging and SubModule
   * references.
   * @param {Discord~Message} [msg] Message to reply to if necessary.
   */
  constructor(game, hg, msg) {
    this.setGame(game);
    this.setParent(hg);
    this.setMessage(msg);
  }
  /**
   * @description Change the GuildGame to simulate.
   *
   * @param {HungryGames~GuildGame} game The new GuildGame.
   */
  setGame(game) {
    this.game = game;
  }
  /**
   * @description Update the reference to the parent {@link HungryGames}.
   *
   * @param {HungryGames} hg New parent reference.
   */
  setParent(hg) {
    this.hg = hg;
  }
  /**
   * @description Update the message to reply to.
   *
   * @param {Discord~Message} msg New message to reference.
   */
  setMessage(msg) {
    this.msg = msg;
  }
  /**
   * @description Simulate a day with the current GuildGame.
   *
   * @param {Function} cb Callback that always fires on completion. The only
   * parameter is a possible error string, null if no error.
   */
  go(cb) {
    if (this.game.currentGame.day.state == 1) {
      this.hg._parent.error(
          'Unable to start simulating because simulation is already in ' +
          'progress.');
      return;
    }
    const locale = this.hg._parent.bot.getLocale &&
        this.hg._parent.bot.getLocale(this.game.id);

    const data = {
      game: this.game.serializable,
      events: Object.assign(
          this.hg._defaultEventStore.serializable,
          {battles: this.hg._defaultBattles}),
      messages: this.hg.messages.getMessages(locale),
    };
    this.game.currentGame.day.state = 1;
    const worker = new Worker(Simulator._workerPath, {workerData: data});
    worker.on('message', (msg) => {
      if (!msg) {
        cb();
        return;
      }
      if (msg.reply && this.msg) {
        this.hg._parent.common.reply(
            this.msg,
            msg.reply.replace(
                /\{prefix\}/g, this.msg.prefix + this.hg._parent.postPrefix),
            msg.reply2 &&
                msg.reply2.replace(
                    /\{prefix\}/g,
                    this.msg.prefix + this.hg._parent.postPrefix));
      }
      if (msg.endGame) {
        this.game.end();
      }
      if (msg.reason) {
        cb(msg.reason);
      }
      if (msg.game) {
        if (this.game.currentGame.day.state === 1) {
          this.game.currentGame = HungryGames.Game.from(msg.game.currentGame);
        } else {
          this.hg._parent.warn(
              'Aborted simulator saving due to not being in loading state.');
        }
        cb();
      }
    });
    worker.on('stdout', (msg) => this.hg._parent.debug(msg));
    worker.on('stderr', (msg) => this.hg._parent.error(msg));
    worker.on('error', (err) => {
      this.hg._parent.error('Simulation worker errored');
      console.error(err);
      this.game.currentGame.day.state = 0;
      this.hg._parent.common.reply(
          this.msg,
          'Simulator crashed for unknown reason while simulating next day.',
          'Try again with `hg next`, otherwise game may be in a bad state.');
    });
    worker.on('exit', (code) => {
      if (code != 0) this.hg._parent.debug('Worker exited with code ' + code);
      if (this.game.currentGame.day.state == 1) {
        this.hg._parent.error(
            'Worker exited but game is still in loading state! Considering ' +
            'this a fatal error!');
        this.game.currentGame.day.state = 0;
        this.hg._parent.common.reply(
            this.msg,
            'Simulator crashed for unknown reason while simulating next day.',
            'Try again with `hg next`, otherwise game may be in a bad state.');
      }
    });
  }
}

/**
 * @description Probability of each amount of people being chosen for an event.
 * Must total to 1.0.
 *
 * @private
 * @static
 * @type {object.<number>}
 * @constant
 * @default
 */
Simulator._multiEventUserDistribution = {
  1: 0.66,
  2: 0.259,
  3: 0.03,
  4: 0.02,
  5: 0.01,
  6: 0.015,
  7: 0.005,
  8: 0.0005,
  9: 0.0005,
};

/**
 * @description If a larger percentage of people die in one day than this value,
 * then show a relevant message.
 *
 * @private
 * @static
 * @type {number}
 * @constant
 * @default
 */
Simulator._lotsOfDeathRate = 0.75;
/**
 * @description If a lower percentage of people die in one day than this value,
 * then show a relevant message.
 *
 * @private
 * @static
 * @type {number}
 * @constant
 * @default
 */
Simulator._littleDeathRate = 0.15;

/**
 * Produce a random number that is weighted by multiEventUserDistribution.
 *
 * @see {@link multiEventUserDistribution}
 *
 * @public
 * @static
 * @returns {number} The weighted number outcome.
 */
Simulator.weightedUserRand = function() {
  let sum = 0;
  const r = Math.random();
  for (const i in Simulator._multiEventUserDistribution) {
    if (typeof Simulator._multiEventUserDistribution[i] !== 'number') {
      throw new Error(
          'Invalid value for multiEventUserDistribution:' +
          Simulator._multiEventUserDistribution[i]);
    } else {
      sum += Simulator._multiEventUserDistribution[i];
      if (r <= sum) return i * 1;
    }
  }
};

/**
 * Pick the players to put into an event.
 *
 * @private
 * @static
 * @param {HungryGames~NormalEvent} evt The event data to pick players for.
 * @param {object} options Options for this game.
 * @param {HungryGames~Player[]} userPool Pool of all remaining players to put
 * into an event.
 * @param {HungryGames~Player[]} deadPool Pool of all dead players that can be
 * revived.
 * @param {HungryGames~Team[]} teams All teams in this game.
 * @param {?Player} weaponWielder A player that is using a weapon in this event,
 * or null if no player is using a weapon.
 * @returns {HungryGames~Player[]} Array of all players that will be affected by
 * this event.
 */
Simulator._pickAffectedPlayers = function(
    evt, options, userPool, deadPool, teams, weaponWielder) {
  const affectedUsers = [];
  const victimOutcome = evt.victim.outcome;
  const attackerOutcome = evt.attacker.outcome;
  const numAttacker = evt.attacker.count;
  const numVictim = evt.victim.count;
  const victimRevived = victimOutcome === 'revived';
  const attackerRevived = attackerOutcome === 'revived';

  let numTeams = 0;
  teams.forEach((el) => {
    if (el.numAlive > 0) numTeams++;
  });
  const collab = options.teammatesCollaborate == 'always' ||
      (options.teammatesCollaborate == 'untilend' && numTeams > 1);

  if (collab && options.teamSize > 0) {
    let isAttacker = false;
    const validTeam = teams.findIndex((team) => {
      if (weaponWielder) {
        isAttacker = options.useEnemyWeapon ? (Math.random() > 0.5) : true;
        return team.players.findIndex((p) => {
          return p === weaponWielder.id;
        }) > -1;
      }

      let canBeVictim = false;
      if (attackerRevived) {
        if (numAttacker <= team.players.length - team.numAlive &&
            numVictim <=
                (victimRevived ?
                     deadPool.length - (team.players.length - team.numAlive) :
                     userPool.length - team.numPool)) {
          isAttacker = true;
        }
      } else if (
        numAttacker <= team.numPool &&
          numVictim <=
              (victimRevived ?
                   deadPool.length - (team.players.length - team.numAlive) :
                   userPool.length - team.numPool)) {
        isAttacker = true;
      }
      if (victimRevived) {
        if (numVictim <= team.players.length - team.numAlive &&
            numAttacker <=
                (attackerRevived ?
                     deadPool.length - (team.players.length - team.numAlive) :
                     userPool.length - team.numPool)) {
          canBeVictim = true;
        }
      } else if (
        numVictim <= team.numPool &&
          numAttacker <=
              (attackerRevived ?
                   deadPool.length - (team.players.length - team.numAlive) :
                   userPool.length - team.numPool)) {
        canBeVictim = true;
      }
      if (!isAttacker && !canBeVictim) {
        return false;
      }
      if (isAttacker && canBeVictim) {
        isAttacker = Math.random() > 0.5;
      }
      return true;
    });
    const findMatching = function(match, mainPool) {
      return mainPool.findIndex((pool) => {
        const teamId = teams.findIndex((team) => {
          return team.players.findIndex((player) => {
            return player == pool.id;
          }) > -1;
        });
        return match ? (teamId == validTeam) : (teamId != validTeam);
      });
    };
    for (let i = 0; i < numAttacker + numVictim; i++) {
      if (victimRevived && i < numVictim) {
        const userIndex = findMatching(!isAttacker, deadPool);
        affectedUsers.push(deadPool.splice(userIndex, 1)[0]);
      } else if (attackerRevived && i >= numVictim) {
        const userIndex = findMatching(isAttacker, deadPool);
        affectedUsers.push(deadPool.splice(userIndex, 1)[0]);
      } else {
        const userIndex = findMatching(
            (i < numVictim && !isAttacker) || (i >= numVictim && isAttacker),
            userPool);
        affectedUsers.push(userPool.splice(userIndex, 1)[0]);
      }
      if (!affectedUsers[i]) {
        console.error(
            'AFFECTED USER IS INVALID:', victimRevived, attackerRevived, i, '/',
            numVictim, numAttacker, 'Pool:', userPool.length, deadPool.length,
            teams[validTeam].players.length - teams[validTeam].numAlive);
      }
    }
  } else {
    let i = weaponWielder ? 1 : 0;
    for (i; i < numAttacker + numVictim; i++) {
      if ((i < numVictim && victimRevived) ||
          (i >= numVictim && attackerRevived)) {
        const userIndex = Math.floor(Math.random() * deadPool.length);
        affectedUsers.push(deadPool.splice(userIndex, 1)[0]);
      } else {
        const userIndex = Math.floor(Math.random() * userPool.length);
        if (weaponWielder && weaponWielder.id == userPool[userIndex].id) {
          i--;
          continue;
        }
        affectedUsers.push(userPool.splice(userIndex, 1)[0]);
      }
    }
    if (weaponWielder) {
      const wielderIndex = userPool.findIndex((u) => u.id == weaponWielder.id);
      affectedUsers.push(userPool.splice(wielderIndex, 1)[0]);
    }
  }
  return affectedUsers;
};
/**
 * Base of all actions to perform on a player.
 *
 * @private
 * @static
 * @param {HungryGames~GuildGame} game Current GuildGame being affected.
 * @param {HungryGames~Player} affected The player to affect.
 * @param {number} kills The number of kills the player gets in this action.
 * @param {{id: string, count: number}} [weapon] The weapon being used if any.
 */
Simulator._effectUser = function(game, affected, kills, weapon) {
  if (weapon) {
    if (!isNaN(affected.weapons[weapon.id])) {
      affected.weapons[weapon.id] =
          affected.weapons[weapon.id] * 1 + weapon.count * 1;
    } else {
      affected.weapons[weapon.id] = weapon.count * 1;
    }
    if (affected.weapons[weapon.id] <= 0) {
      delete affected.weapons[weapon.id];
    }
  }
  affected.kills += kills;
};

/**
 * Kill the given player in the given guild game.
 *
 * @private
 * @static
 * @param {HungryGames~GuildGame} game Current GuildGame being affected.
 * @param {HungryGames~Player} a The player to affect.
 * @param {number} k The number of kills the player gets in this action.
 * @param {{name: string, count: number}} [w] The weapon being used if any.
 */
Simulator._killUser = function(game, a, k, w) {
  Simulator._effectUser(game, a, k, w);
  a.bleeding = 0;
  a.state = 'dead';
  a.weapons = {};
  if (!a.living) return;
  a.living = false;
  a.rank = game.currentGame.numAlive--;
  a.dayOfDeath = game.currentGame.day.num;
  if (game.options.teamSize > 0) {
    const team = game.currentGame.teams.find((team) => {
      return team.players.findIndex((obj) => {
        return a.id == obj;
      }) > -1;
    });
    if (!team) {
      console.log('FAILED TO FIND ADEQUATE TEAM FOR USER', a.id);
    } else {
      team.numAlive--;
      if (team.numAlive === 0) {
        let teamsLeft = 0;
        game.currentGame.teams.forEach((obj) => {
          if (obj.numAlive > 0) teamsLeft++;
        });
        team.rank = teamsLeft + 1;
      }
    }
  }
};

/**
 * Wound the given player in the given guild game.
 *
 * @private
 * @static
 * @param {HungryGames~GuildGame} game Current GuildGame being affected.
 * @param {HungryGames~Player} a The player to affect.
 * @param {number} k The number of kills the player gets in this action.
 * @param {{name: string, count: number}} [w] The weapon being used if any.
 */
Simulator._woundUser = function(game, a, k, w) {
  Simulator._effectUser(game, a, k, w);
  a.state = 'wounded';
};
/**
 * Heal the given player in the given guild game.
 *
 * @private
 * @static
 * @param {HungryGames~GuildGame} game Current GuildGame being affected.
 * @param {HungryGames~Player} a The player to affect.
 * @param {number} k The number of kills the player gets in this action.
 * @param {{name: string, count: number}} [w] The weapon being used if any.
 */
Simulator._restoreUser = function(game, a, k, w) {
  Simulator._effectUser(game, a, k, w);
  a.state = 'normal';
  a.bleeding = 0;
};
/**
 * Revive the given player in the given guild game.
 *
 * @private
 * @static
 * @param {HungryGames~GuildGame} game Current GuildGame being affected.
 * @param {HungryGames~Player} a The player to affect.
 * @param {number} k The number of kills the player gets in this action.
 * @param {{name: string, count: number}} [w] The weapon being used if any.
 */
Simulator._reviveUser = function(game, a, k, w) {
  Simulator._effectUser(game, a, k, w);
  a.state = a.living ? 'normal' : 'zombie';
  a.bleeding = 0;
  game.currentGame.includedUsers.forEach((obj) => {
    if (!obj.living && obj.rank < a.rank) obj.rank++;
  });
  a.rank = 1;
  if (a.living) return;
  a.living = true;
  game.currentGame.numAlive++;
  if (game.options.teamSize > 0) {
    const team = game.currentGame.teams.find((obj) => {
      return obj.players.findIndex((obj) => {
        return a.id == obj;
      }) > -1;
    });
    team.numAlive++;
    game.currentGame.teams.forEach((obj) => {
      if (obj.numAlive === 0 && obj.rank < team.rank) obj.rank++;
    });
    team.rank = 1;
  }
};

/**
 * @description Apply the given outcome to a player in the given guild game.
 *
 * @private
 * @static
 * @param {HungryGames~GuildGame} game Current GuildGame being affected.
 * @param {HungryGames~Player} a The player to affect.
 * @param {number} k The number of kills the player gets in this action.
 * @param {{name: string, count: number}} [w] The weapon being used if any.
 * @param {string} outcome The outcome to apply.
 * @returns {boolean} True if valid outcome was successfully applied, false
 * otherwise. ('nothing' is considered not valid, but outcome will still be
 * applied).
 */
Simulator._applyOutcome = function(game, a, k, w, outcome) {
  switch (outcome) {
    case 'dies':
      Simulator._killUser(game, a, k, w);
      return true;
    case 'revived':
      Simulator._reviveUser(game, a, k, w);
      return true;
    case 'thrives':
      Simulator._restoreUser(game, a, k, w);
      return true;
    case 'wounded':
      Simulator._woundUser(game, a, k, w);
      return true;
    case 'nothing':
      Simulator._effectUser(game, a, k, w);
      return false;
    default:
      return false;
  }
};

/**
 * Pick event that satisfies all requirements and settings.
 *
 * @private
 * @static
 * @param {HungryGames~Player[]} userPool Pool of players left to chose from
 * in this day.
 * @param {HungryGames~NormalEvent[]} eventPool Pool of all events available to
 * choose at this time.
 * @param {object} options The options set in the current game.
 * @param {number} numAlive Number of players in the game still alive.
 * @param {number} numTotal Number of players in the game total.
 * @param {HungryGames~Team[]} teams Array of teams in this game.
 * @param {HungryGames~OutcomeProbabilities} probOpts Death rate weights.
 * @param {?Player} weaponWielder A player that is using a weapon in this
 * event, or null if no player is using a weapon.
 * @param {string} weaponId ID of the weapon the player is trying to use.
 * @returns {?HungryGames~NormalEvent} The chosen event that satisfies all
 * requirements, or null if something went wrong.
 */
Simulator._pickEvent = function(
    userPool, eventPool, options, numAlive, numTotal, teams, probOpts,
    weaponWielder, weaponId) {
  if (eventPool) eventPool = eventPool.filter((el) => el);
  // const fails = [];
  let loop = 0;
  while (loop < 100) {
    loop++;
    if (!eventPool || eventPool.length == 0) {
      // fails.push('No Events');
      break;
    }
    const eventIndex = Simulator._probabilityEvent(
        eventPool, probOpts, options.customEventWeight);
    const eventTry = eventPool[eventIndex];
    if (!eventTry) {
      /* if (fails.length < 3) {
        console.error('Invalid Event:', eventTry);
      }
      fails.push('Invalid Event'); */
      eventPool.splice(eventIndex, 1);
      continue;
    }

    let numAttacker = eventTry.attacker.count * 1;
    let numVictim = eventTry.victim.count * 1;

    const victimRevived = eventTry.victim.outcome === 'revived';
    const attackerRevived = eventTry.attacker.outcome === 'revived';

    let eventEffectsNumMin = 0;
    let eventRevivesNumMin = 0;
    victimRevived ? (eventRevivesNumMin += Math.abs(numVictim)) :
                    (eventEffectsNumMin += Math.abs(numVictim));
    attackerRevived ? (eventRevivesNumMin += Math.abs(numAttacker)) :
                      (eventEffectsNumMin += Math.abs(numAttacker));

    // If the chosen event requires more players than there are remaining,
    // pick a new event.
    if (eventEffectsNumMin > userPool.length) {
      /* fails.push(
          'Event too large (' + eventEffectsNumMin + ' > ' + userPool.length +
          '): ' + eventIndex + ' V:' + eventTry.victim.count + ' A:' +
          eventTry.attacker.count + ' M:' + eventTry.message); */
      continue;
    } else if (eventRevivesNumMin > numTotal - numAlive) {
      /* fails.push(
          'Event too large (' + eventRevivesNumMin + ' > ' +
          (numTotal - numAlive) + '): ' + eventIndex + ' V:' +
          eventTry.victim.count + ' A:' + eventTry.attacker.count + ' M:' +
          eventTry.message); */
      continue;
    }

    const consumes = Math.abs(
        Simulator._parseConsumeCount(
            eventTry.consumes, numVictim, numAttacker));
    if (weaponWielder && weaponId) {
      if (consumes > weaponWielder.weapons[weaponId]) {
        /* fails.push(
            'Not enough consumables (' + consumes + ' > ' +
            weaponWielder.weapons[weaponId] + '): ' + eventIndex + ' V:' +
            eventTry.victim.count + ' A:' + eventTry.attacker.count + ' M:' +
            eventTry.message); */
        continue;
      }
    }

    const multiAttacker = numAttacker < 0;
    const multiVictim = numVictim < 0;
    const attackerMin = -numAttacker;
    const victimMin = -numVictim;
    if (multiAttacker || multiVictim) {
      let count = 0;
      while (count++ < 100) {
        if (multiAttacker) {
          numAttacker = Simulator.weightedUserRand() + (attackerMin - 1);
        }
        if (multiVictim) {
          numVictim = Simulator.weightedUserRand() + (victimMin - 1);
        }
        if (weaponWielder && weaponId &&
            Simulator._parseConsumeCount(
                eventTry.consumes, numVictim, numAttacker) >
                weaponWielder.weapons[weaponId]) {
          continue;
        } else if (victimRevived && attackerRevived) {
          if (numAttacker + numVictim <= numTotal - numAlive) break;
        } else if (victimRevived) {
          if (numAttacker <= userPool.length &&
              numVictim <= numTotal - numAlive) {
            break;
          }
        } else if (attackerRevived) {
          if (numAttacker <= numTotal - numAlive &&
              numVictim <= userPool.length) {
            break;
          }
        } else if (numAttacker + numVictim <= userPool.length) {
          break;
        }
      }
      if (count >= 100) {
        self.error('Infinite loop while picking player count.');
        // fails.push('Infinite loop while picking player count.');
        continue;
      }
    }

    const failReason = Simulator._validateEventRequirements(
        victimRevived ? 0 : numVictim, attackerRevived ? 0 : numAttacker,
        userPool, numAlive, teams, options, eventTry.victim.outcome == 'dies',
        eventTry.attacker.outcome == 'dies', weaponWielder);
    if (failReason) {
      /* fails.push(
          'Fails event requirement validation: ' + eventIndex + ' ' +
          failReason); */
      continue;
    }

    const finalEvent = HungryGames.NormalEvent.from(eventPool[eventIndex]);

    finalEvent.attacker.count = numAttacker;
    finalEvent.victim.count = numVictim;

    return finalEvent;
  }
  return null;
};
/**
 * Ensure teammates don't attack each other.
 *
 * @private
 * @static
 * @param {number} numVictim The number of victims in the event.
 * @param {number} numAttacker The number of attackers in the event.
 * @param {HungryGames~Player[]} userPool Pool of all remaining players to put
 * into an event.
 * @param {HungryGames~Team[]} teams All teams in this game.
 * @param {object} options Options for this game.
 * @param {boolean} victimsDie Do the victims die in this event?
 * @param {boolean} attackersDie Do the attackers die in this event?
 * @param {?Player} weaponWielder A player that is using a weapon in this
 * event, or null if no player is using a weapon.
 * @returns {?string} String describing failing check, or null of pass.
 */
Simulator._validateEventTeamConstraint = function(
    numVictim, numAttacker, userPool, teams, options, victimsDie, attackersDie,
    weaponWielder) {
  let numTeams = 0;
  teams.forEach((el) => {
    if (el.numAlive > 0) numTeams++;
  });
  const collab = options.teammatesCollaborate == 'always' ||
      (options.teammatesCollaborate == 'untilend' && numTeams > 1);
  if (collab && options.teamSize > 0) {
    if (weaponWielder) {
      let numTeams = 0;
      for (let i = 0; i < teams.length; i++) {
        const team = teams[i];
        let numPool = 0;

        team.players.forEach((player) => {
          if (userPool.find((pool) => pool.id == player && pool.living)) {
            numPool++;
          }
        });

        team.numPool = numPool;
        if (numPool > 0) numTeams++;
      }
      if (numTeams < 2) {
        if (attackersDie || victimsDie) {
          return 'TEAM_WEAPON_NO_OPPONENT';
        }
      }
      const attackerTeam = teams.find((team) => {
        return team.players.findIndex((p) => {
          return p === weaponWielder.id;
        }) > -1;
      });
      if (!attackerTeam) {
        self.error(weaponWielder.id + ' not on any team');
        return 'TEAM_WEAPON_NO_TEAM';
      }
      return !(numAttacker <= attackerTeam.numPool &&
               numVictim <= userPool.length - attackerTeam.numPool) &&
          'TEAM_WEAPON_TOO_LARGE' ||
          null;
    } else {
      let largestTeam = {index: 0, size: 0};
      let numTeams = 0;
      for (let i = 0; i < teams.length; i++) {
        const team = teams[i];
        let numPool = 0;

        team.players.forEach((player) => {
          if (userPool.findIndex((pool) => {
            return pool.id == player && pool.living;
          }) > -1) {
            numPool++;
          }
        });

        team.numPool = numPool;
        if (numPool > largestTeam.size) {
          largestTeam = {index: i, size: numPool};
        }
        if (numPool > 0) numTeams++;
      }
      if (numTeams < 2) {
        if (attackersDie || victimsDie) {
          return 'TEAM_NO_OPPONENT';
        }
      }
      return !((numAttacker <= largestTeam.size &&
                numVictim <= userPool.length - largestTeam.size) ||
               (numVictim <= largestTeam.size &&
                numAttacker <= userPool.length - largestTeam.size)) &&
          'TEAM_TOO_LARGE' ||
          null;
    }
  }
  return null;
};
/**
 * Ensure the event we choose will not force all players to be dead.
 *
 * @private
 * @static
 * @param {number} numVictim Number of victims in this event.
 * @param {number} numAttacker Number of attackers in this event.
 * @param {number} numAlive Total number of living players left in the game.
 * @param {object} options The options set for this game.
 * @param {boolean} victimsDie Do the victims die in this event?
 * @param {boolean} attackersDie Do the attackers die in this event?
 * @returns {boolean} Will this event follow current options set about number
 * of victors required.
 */
Simulator._validateEventVictorConstraint = function(
    numVictim, numAttacker, numAlive, options, victimsDie, attackersDie) {
  if (!options.allowNoVictors) {
    let numRemaining = numAlive;
    if (victimsDie) numRemaining -= numVictim;
    if (attackersDie) numRemaining -= numAttacker;
    return numRemaining >= 1;
  }
  return true;
};
/**
 * Ensure the number of users in an event is mathematically possible.
 *
 * @private
 * @static
 * @param {number} numVictim Number of victims in this event.
 * @param {number} numAttacker Number of attackers in this event.
 * @param {HungryGames~Player[]} userPool Pool of all remaining players to put
 * into an event.
 * @param {number} numAlive Total number of living players left in the game.
 * @returns {boolean} If the event requires a number of players that is valid
 * from the number of players left to choose from.
 */
Simulator._validateEventNumConstraint = function(
    numVictim, numAttacker, userPool, numAlive) {
  return numVictim + numAttacker <= userPool.length &&
      numVictim + numAttacker <= numAlive;
};
/**
 * Ensure the event chosen meets all requirements for actually being used in
 * the current game.
 *
 * @private
 * @static
 * @param {number} numVictim Number of victims in this event.
 * @param {number} numAttacker Number of attackers in this event.
 * @param {HungryGames~Player[]} userPool Pool of all remaining players to put
 * into an event.
 * @param {number} numAlive Total number of living players left in the game.
 * @param {HungryGames~Team[]} teams All teams in this game.
 * @param {object} options The options set for this game.
 * @param {boolean} victimsDie Do the victims die in this event?
 * @param {boolean} attackersDie Do the attackers die in this event?
 * @param {?Player} weaponWielder A player that is using a weapon in this
 * event, or null if no player is using a weapon.
 * @returns {?string} String of failing constraint check, or null if passes.
 */
Simulator._validateEventRequirements = function(
    numVictim, numAttacker, userPool, numAlive, teams, options, victimsDie,
    attackersDie, weaponWielder) {
  if (!Simulator._validateEventNumConstraint(
      numVictim, numAttacker, userPool, numAlive)) {
    return 'NUM_CONSTRAINT';
  }
  const failReason = Simulator._validateEventTeamConstraint(
      numVictim, numAttacker, userPool, teams, options, victimsDie,
      attackersDie, weaponWielder);
  if (failReason) {
    return 'TEAM_CONSTRAINT-' + failReason;
  }
  if (!Simulator._validateEventVictorConstraint(
      numVictim, numAttacker, numAlive, options, victimsDie,
      attackersDie)) {
    return 'VICTOR_CONSTRAINT';
  }
  return null;
};

/**
 * Produce a random event that using probabilities set in options.
 *
 * @private
 * @static
 * @param {HungryGames~NormalEvent[]} eventPool The pool of all events to
 * consider.
 * @param {{
 *   kill: number,
 *   wound: number,
 *   thrive: number,
 *   nothing: number
 * }} probabilityOpts The probabilities of each type of event being used.
 * @param {number} [customWeight=1] The weight of custom events.
 * @param {number} [recurse=0] The current recursive depth.
 * @returns {number} The index of the event that was chosen.
 */
Simulator._probabilityEvent = function(
    eventPool, probabilityOpts, customWeight = 1, recurse = 0) {
  const vOut = Simulator._pickWeightedOutcome(probabilityOpts);
  const aOut = Simulator._pickWeightedOutcome(probabilityOpts);

  const finalPool = [];

  for (let i = 0; i < eventPool.length; i++) {
    if (eventPool[i].attacker.outcome == aOut &&
        eventPool[i].victim.outcome == vOut) {
      finalPool.push(i);
    }
  }
  if (finalPool.length == 0) {
    if (recurse < 10) {
      return Simulator._probabilityEvent(
          eventPool, probabilityOpts, customWeight, recurse + 1);
    } else {
      /* self.error(
          'Failed to find event with probabilities: ' +
          JSON.stringify(probabilityOpts) + ' from ' + eventPool.length +
          ' events. Victim: ' + vOut + ' Attacker: ' + aOut); */
      return Math.floor(Math.random() * eventPool.length);
    }
  } else {
    let total = finalPool.length;
    if (customWeight !== 1) {
      finalPool.forEach((el) => {
        if (eventPool[el].custom) total += customWeight - 1;
      });
    }
    const pick = Math.random() * total;
    return finalPool.find((el) => {
      total -= eventPool[el].custom ? customWeight : 1;
      return total < pick;
    });
  }
};

/**
 * @description Pick an outcome given the probability options.
 * @private
 * @static
 * @param {{
 *   kill: number,
 *   wound: number,
 *   thrive: number,
 *   nothing: number
 * }} probabilityOpts The probabilities of each type of event being used.
 * @returns {string} The outcome. One of "dies", "revived", "thrives",
 * "wounded", or "nothing".
 */
Simulator._pickWeightedOutcome = function(probabilityOpts) {
  let probTotal = 0;
  if (typeof probabilityOpts.kill === 'number') {
    probTotal += probabilityOpts.kill;
  } else {
    probabilityOpts.kill = 0;
  }
  if (typeof probabilityOpts.nothing === 'number') {
    probTotal += probabilityOpts.nothing;
  } else {
    probabilityOpts.nothing = 0;
  }
  if (typeof probabilityOpts.revive === 'number') {
    probTotal += probabilityOpts.revive;
  } else {
    probabilityOpts.revive = 0;
  }
  if (typeof probabilityOpts.thrive === 'number') {
    probTotal += probabilityOpts.thrive;
  } else {
    probabilityOpts.thrive = 0;
  }
  if (typeof probabilityOpts.wound === 'number') {
    probTotal += probabilityOpts.wound;
  } else {
    probabilityOpts.wound = 0;
  }

  const value = Math.random() * probTotal;

  let type;
  if (value > (probTotal -= probabilityOpts.nothing)) type = 'nothing';
  else if (value > (probTotal -= probabilityOpts.revive)) type = 'revived';
  else if (value > (probTotal -= probabilityOpts.thrive)) type = 'thrives';
  else if (value > (probTotal -= probabilityOpts.wound)) type = 'wounded';
  else type = 'dies';

  return type;
};

/**
 * Parse the number of items consumed from the given consumed value, and number
 * of victims and attackers.
 *
 * @private
 * @static
 * @param {string} consumeString The consumes value for the event.
 * @param {number} numVictim The number of victims in the event.
 * @param {number} numAttacker The number of attackers in the event.
 * @returns {number} The number of consumed items.
 */
Simulator._parseConsumeCount = function(consumeString, numVictim, numAttacker) {
  const consumedMatch = (consumeString + '').match(/^(\d*)(V|A)?$/);
  if (!consumedMatch) {
    return 0;
  } else if (consumedMatch[2] == 'V') {
    return numVictim * (consumedMatch[1] || 1);
  } else if (consumedMatch[2] == 'A') {
    return numAttacker * (consumedMatch[1] || 1);
  } else {
    return consumedMatch[1] * 1;
  }
};

/**
 * @description Format an event message for the given weapon information.
 * @public
 * @static
 * @param {HungryGames~NormalEvent} eventTry The event to format.
 * @param {HungryGames~Player} userWithWeapon The player using the weapon.
 * @param {string} ownerName The formated name to insert fot the weapon owner.
 * @param {boolean} firstAttacker Is the weapon owner the first attacker in list
 * of affected users.
 * @param {string} weaponId The id of the chosen weapon.
 * @param {object.<HungryGames~WeaponEvent>} weapons The default weapons object
 * injected with custom weapons.
 * @param {number} [countOverride] If specified, this value is used as the final
 * amount the player will end up with, instead of using the calculated value.
 * @returns {string} Additional subMessage.
 */
Simulator.formatWeaponEvent = function(
    eventTry, userWithWeapon, ownerName, firstAttacker, weaponId, weapons,
    countOverride) {
  let subMessage = '';

  const numVictim = eventTry.victim.count;
  const numAttacker = eventTry.attacker.count;
  const found = eventTry.consumes && eventTry.consumes.find &&
      eventTry.consumes.find((el) => el.id === weaponId);
  const consumed = Simulator._parseConsumeCount(
      found ? found.count : eventTry.consumes, numVictim, numAttacker);
  const count =
      (countOverride != null ? countOverride :
                               userWithWeapon.weapons[weaponId] - consumed) ||
      0;
  const chosenWeapon = weapons[weaponId];
  const weaponName = chosenWeapon && chosenWeapon.name || weaponId;
  if (count <= 0) {
    if ((userWithWeapon.weapons[weaponId] - consumed || 0) ==
        (countOverride || 0)) {
      delete userWithWeapon.weapons[weaponId];
    }

    let consumableName = weaponName;
    if (chosenWeapon) {
      if (chosenWeapon.consumable) {
        consumableName =
            chosenWeapon.consumable.replace(/\[C([^|]*)\|([^\]]*)\]/g, '$2');
      } else if (chosenWeapon.name) {
        consumableName =
            chosenWeapon.name.replace(/\[C([^|]*)\|([^\]]*)\]/g, '$2');
      } else {
        consumableName += 's';
      }
    }
    subMessage = `\n${ownerName} runs out of ${consumableName}.`;
  } else if (consumed != 0) {
    let consumableName = weaponName;
    const count = consumed;
    if (chosenWeapon.consumable) {
      consumableName = chosenWeapon.consumable.replace(
          /\[C([^|]*)\|([^\]]*)\]/g, (count == 1 ? '$1' : '$2'));
    } else if (chosenWeapon.name) {
      consumableName = chosenWeapon.name.replace(
          /\[C([^|]*)\|([^\]]*)\]/g, (count == 1 ? '$1' : '$2'));
    } else if (count != 1) {
      consumableName += 's';
    }
    subMessage = `\n${ownerName} lost ${count} ${consumableName}.`;
  }

  let owner = 'their';
  if (numAttacker > 1 || (numAttacker == 1 && !firstAttacker)) {
    owner = `${ownerName}'s`;
  }
  if (!eventTry.message) {
    eventTry.message =
        HungryGames.WeaponEvent.action
            .replace(/\{weapon\}/g, `${owner} ${weaponName}`)
            .replace(/\{action\}/g, eventTry.action)
            .replace(/\[C([^|]*)\|([^\]]*)\]/g, (consumed == 1 ? '$1' : '$2'));
  } else {
    eventTry.message =
        eventTry.message
            .replace(/\[C([^|]*)\|([^\]]*)\]/g, (consumed == 1 ? '$1' : '$2'))
            .replace(/\{owner\}/g, owner);
  }
  return subMessage;
};

/**
 * @description Format the text that shows all users' inventories in an event.
 * @public
 * @static
 * @param {HungryGames~NormalEvent} eventTry The event to format inventories
 * for.
 * @param {HungryGames~Player[]} affectedUsers Array of all player affected by
 * this event.
 * @param {object.<HungryGames~WeaponEvent>} weapons The default weapons object
 * injected with custom weapons.
 * @param {string} nameFormat The format for
 * {@link HungryGames~Grammar~formatMultiNames}.
 * @returns {string} The additional text to append.
 */
Simulator.formatWeaponCounts = function(
    eventTry, affectedUsers, weapons, nameFormat) {
  const numVictim = Math.abs(eventTry.victim.count);
  const numAttacker = Math.abs(eventTry.attacker.count);
  const finalConsumeList = {};


  const getWeaponCount = function(el) {
    const weapon = weapons[el[0]];
    const weaponName = weapon && weapon.name || el[0];
    let consumableName = weaponName;
    const count = el[1];
    if (!weapon) {
      console.error('Unable to find weapon ' + el[0]);
      return `(Unknown weapon ${weaponName}. Was it deleted?)`;
    }
    if (weapon.consumable) {
      consumableName = weapon.consumable.replace(
          /\[C([^|]*)\|([^\]]*)\]/g, (count == 1 ? '$1' : '$2'));
    } else if (count != 1) {
      consumableName += 's';
    }
    return `${count || 0} ${consumableName}`;
  };
  for (let i = 0; i < numVictim + numAttacker; i++) {
    const group = i < numVictim ? eventTry.victim : eventTry.attacker;
    const evtGroup = group.weapon || group.weapons;
    if (!evtGroup || evtGroup.length === 0) {
      if (i < numVictim) {
        i += numVictim - 1;
      } else {
        i += numAttacker - 1;
      }
      continue;
    }
    const user = affectedUsers[i];
    let entries = [];
    if (user && user.weapons) entries = Object.entries(user.weapons);
    if (entries.length === 0) continue;
    const consumableList = entries.filter((el) => el[0]).map(getWeaponCount);
    const list = consumableList.join(', ');
    if (!finalConsumeList[list]) {
      finalConsumeList[list] = [];
    }
    finalConsumeList[list].push(user);
  }

  const subMessage = [];
  Object.entries(finalConsumeList).forEach((el) => {
    const multi = HungryGames.Grammar.formatMultiNames(el[1], nameFormat);
    const has = el[1].length == 1 ? 'has' : 'have';
    subMessage.push(`\n${multi} now ${has} ${el[0]}.`);
  });
  return subMessage.join('');
};

/**
 * Relative path from CWD where the simulation worker is located.
 *
 * @private
 * @static
 * @type {string}
 * @default
 * @constant
 */
Simulator._workerPath = './src/hg/simulator/worker.js';

module.exports = Simulator;