const util = require('util'); /** * Counts the occurrences of a number in an array of numbers * * @param inputArray - array of numbers * @param checkNumber - number to count occurrences of * @returns number of occurrences * */ function countOccurrencesOfNumber(inputArray: Array, checkNumber: number): number { return inputArray.filter(c => c === checkNumber).length; } /** * Generate a random integer between *1* and `max` (inclusive) * * @param max - upper bound of random integer to generate * @returns random integer */ function randIntMinOne(max: number): number { return (1 + Math.floor(Math.random() * Math.floor(max))); } interface Dice { readonly sides: number; readonly type: string; } export enum Outcomes { Success = "Success", Fail = "Failure", CritSuccess = "Critical Success", CritFail = "Critical Failure", Other = "Something Else" } export interface DiceResult { total: number; dice: Array; olddice?: Array; reroll?: boolean; outcome?: Outcomes; balance?: number; } /** * Simple d10, implements `roll()` */ class D10 implements Dice { sides: number = 10; type: string = "d10" roll(numberToRoll: number): DiceResult { let results: DiceResult = { total: 0, dice: [] }; for (let i = 0; i < numberToRoll; i++) { results.dice.push(randIntMinOne(this.sides)); } results.total = results.dice.reduce((acc: number, curr: number) => acc + curr); return results; } [util.inspect.custom](): string { return this.type; } /** * Roll a specified number of dice * * @param numberToRoll - integer number of dice to roll * @returns DiceResult: .total and .dice (individual dice) * * @example Roll 4d10 * * ``` * D10.roll(4) * { total: 16, dice [ 3, 5, 6, 2] } * */ } export class Asphodice extends D10 { readonly type: string = "asphodice"; readonly passCrit: number = 10; readonly failCrit: number = 1; readonly successCutOff: number = 6; // this or larger /** * Re-roll the high dice (ie >= 6) for a chance of failure * happens on eg 1 * * @remarks * Basically we want to remove a die from the original result, * as long as that die isn't a 10, and is above the * cutoff. So we filter to get rerollCandidates, find the * highest value and get the index of that value in the * original results. We use that to splice() out (remove) that * value and push the rerolled dice onto the modified array * * @param rerollDice * @returns rerollOutcome * * @example Re-roll High Dice on 1 * ``` * rerollHighDice([8, 1, 1, 4, 7, 10]); * [ 1, 1, 4, 7, 10, 6] * ``` * * ## Explanation * * - first filter dice to be not 10 and >= 6 (see {@link Asphodice.successCutOff}) * - rerollCandidates are [8, 7] * - max([8, 7] = 8 * - indexOf(8) = 0 * - rerollResult = 6 (see {@link randIntMinOne()}) * - `splice()` replaced dice out of array = [ 1, 1, 4, 7, 10 ] * - `push()` new dice = [ 1, 1, 4, 7, 10, 6] */ rerollHighDice (rerollDice: Array): Array { let rerollCandidates: Array = rerollDice.filter(die => (die < 10 && die >= this.successCutOff)); let maxValue: number = Math.max(...rerollCandidates); let maxIndex = rerollDice.indexOf(maxValue); let rerollResult: number = randIntMinOne(this.sides); rerollDice.splice(maxIndex, 1); rerollDice.push(rerollResult); return rerollDice } /** * Re-roll low dice (ie < 6) for chance of success (ie getting >= 6) * * @remarks * * Generally happens on a 10. * * @see {@link Asphodice.rerollHighDice} for a fuller explanation * for what process in opposite direction */ rerollLowDice (rerollDice: Array): Array { let rerollCandidates: Array = rerollDice.filter(die => (die > 1 && die < this.successCutOff)); let minValue: number = Math.min(...rerollCandidates); let minIndex = rerollDice.indexOf(minValue); let rerollResult: number = randIntMinOne(this.sides); rerollDice.splice(minIndex, 1); rerollDice.push(rerollResult); return rerollDice } /** * Used to determine if all dice are above Asphodice.successCutOff * * @param checkDice - Array of Dice to test * * @see {@link Asphodice.roll} where it is used to determine if * a re-roll can be skipped * * @see {@link allBelowCutOff} also. */ allAboveCutOff (checkDice: Array): boolean { // if filtering those *below* cutoff results in empty set // then all must be above cutoff return ((checkDice.filter(die => die < this.successCutOff).length == 0)); } /** * Used to determine if all dice are below Asphodice.successCutOff * * @param checkDice - Array of Dice to test * * @see {@link Asphodice.roll} where it is used to determine if * a re-roll can be skipped * * @see {@link allAboveCutOff} also. */ allBelowCutOff (checkDice: Array): boolean { // if filtering those *above or equal to* cutoff results in empty set // then all must be below cutoff return ((checkDice.filter(die => die >= this.successCutOff).length == 0)); } /** * Determine balance of outcomes - ie successes minus failures * * @remarks * * Success = 1, failure = -1 * * Particularly for Asphodice, we aren't terribly concerned with the * numeric sum total of the dice values, we are more concerned with * the overall outcome (see {@link Outcomes}) and for narrative purposes * perhaps the balance of outcomes; for example, 1 success may be * narrated differently than 4 successes. * * **Special note**: This ignores crits! **End special note** * * TODO: Special consideration of a single die roll (?) * * @param resultDice - the final dice after any re-rolls * @returns Single positive/negative number with balance of roll outcomes * * @example Count Outcomes of 5 Dice * ``` * countOutcomeBalance([ 7, 4, 4, 9, 1 ]) * -1 * ``` * * - 7 and 9 are above successCutOff → +2 * - 4, 4, 1 are below successCutOff → -3 */ countOutcomeBalance (resultDice: Array): number{ // fun with ternary operators return resultDice.reduce( (acc: number, curr: number) => { return (curr < this.successCutOff) ? acc - 1 : acc + 1 }, 0); // PS also good practice to supply initialValue (0) } /** * Determine and return outcome of roll * * @remarks * * Determines outcome as in "Success", "Failure", etc. Assumes that `resultDice` is * the 'final' result after any rerolls. * * @see {@link:countOutcomeBalance} */ checkOutcome (resultDice: Array): Outcomes{ // Note: currently, one success = Success, regardless of number of failures // TODO: Critical failures, once decided with Mao if (this.allBelowCutOff(resultDice)) { return Outcomes.Fail; } else { return Outcomes.Success; } } /** * 'Cancel out' reroll dice * * @remarks * * Helper function. * * **In the context of deciding a reroll only**, the reroll dice can * 'cancel' each other out. The dice themselves remain intact for the * purposes of determining the final outcome. * * @returns Filtered dice */ cancelRerollDice (resultDice: Array): Array { // Naive approach for now: // - count 10s and 1s // - remove all 10s and 1s from input array // - re-add 10s/1s after 'cancelling' let tens = 0; let ones = 0; // count for (let die of resultDice) { if (die === 10) { tens++; } else if (die === 1) { ones++; } } // remove any reroll dice let outputDice = resultDice.filter( die => (die != 1 && die != 10) ); let balance = tens - ones; if (balance < 0) { // add balance of ones outputDice.push(...Array(Math.abs(balance)).fill(1)); } else if (balance > 0) { // add balance of tens outputDice.push(...Array(Math.abs(balance)).fill(10)); } return outputDice; } /** * Determines if a reroll is needed * * @remarks * * In some cases we don't reroll: * - reroll dice cancel each other out * - all are above cutoff (=> auto-success -- NB TODO crits) * - all below cutoff */ rerollNeeded(resultDice: Array): boolean{ // 'Cancel out' matching re-roll dice let cancelledDice = this.cancelRerollDice(resultDice); // 1. if no re-rolls we can finish here if (!(cancelledDice.includes(this.passCrit) || resultDice.includes(this.failCrit))) { return false; } // count successes and fails let rerollGood = countOccurrencesOfNumber(cancelledDice, this.passCrit); let rerollBad = countOccurrencesOfNumber(cancelledDice, this.failCrit); // 2. only reroll if they don't cancel each other out if (rerollGood == rerollBad) { return false; } // 3. If all dice are above/below cutoff we don't need to reroll if (this.allAboveCutOff(cancelledDice) || this.allBelowCutOff(cancelledDice)) { return false; } // We should re-roll return true; } /** * Roll an Asphodie or Asphodice * * @param numToRoll * @returns a {@link DiceResult} with additional properties: * - reroll: boolean - whether we rerolled * - olddice: Array - original roll * - dice: Array - final outcome * - balance: number - +/- of outcomes (successes/failures) */ roll (numToRoll: number): DiceResult { let results: DiceResult = { total: 0, dice: [] }; // Initial roll for (let i = 0; i < numToRoll; i++) { results.dice.push(randIntMinOne(this.sides)); } // Reroll? if (this.rerollNeeded(results.dice)) { // Reminder: arr1 = arr2 is a copy by reference! let olddice = results.dice.slice(); let rerollGood = countOccurrencesOfNumber(olddice, this.passCrit); let rerollBad = countOccurrencesOfNumber(olddice, this.failCrit); if (rerollGood > rerollBad) { // Re-roll low (<6) dice for chance of success results.dice = this.rerollLowDice(results.dice); } else { // Re-roll high (>=6) dice for chance of failure results.dice = this.rerollHighDice(results.dice); } results.olddice = olddice; results.reroll = true; } results.balance = this.countOutcomeBalance(results.dice); results.total = results.dice.reduce((acc: number, curr: number) => acc + curr); // Finally, once we're done with rerolls etc, determine outcome results.outcome = this.checkOutcome(results.dice); return results; } }