diff --git a/asphodice.ts b/asphodice.ts new file mode 100644 index 0000000..4bc90ed --- /dev/null +++ b/asphodice.ts @@ -0,0 +1,379 @@ +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" +} + +interface DiceResult { + total: number; + dice: Array; + olddice?: Array; + reroll?: boolean; + outcome?: Outcomes; + balance?: number; + +} + +interface ItemCount { + item: string | number | Outcomes; + count: number; +} + +declare let ItemCountSet: Array; + + +/** + * 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; + } +} + diff --git a/dice.ts b/dice.ts index 503162f..4d5d91b 100644 --- a/dice.ts +++ b/dice.ts @@ -1,381 +1,4 @@ -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" -} - -interface DiceResult { - total: number; - dice: Array; - olddice?: Array; - reroll?: boolean; - outcome?: Outcomes; - balance?: number; - -} - -interface ItemCount { - item: string | number | Outcomes; - count: number; -} - -declare let ItemCountSet: Array; - - -/** - * 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; - } -} +import { Asphodice } from "./asphodice"; let asphodice: Asphodice = new Asphodice(); let number: number = 4; diff --git a/test/test-dice.spec.ts b/test/test-dice.spec.ts index d8ae43c..36572af 100644 --- a/test/test-dice.spec.ts +++ b/test/test-dice.spec.ts @@ -1,5 +1,5 @@ -import { Asphodice } from "../dice"; -import { Outcomes } from "../dice"; +import { Asphodice } from "../asphodice"; +import { Outcomes } from "../asphodice"; import { expect } from 'chai'; import 'mocha';