// 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;