Roll dice (eg Asphodice) and show outcomes https://rpg.bertieb.org/dice-roller/
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

372 lines
10 KiB

  1. const util = require('util');
  2. /**
  3. * Counts the occurrences of a number in an array of numbers
  4. *
  5. * @param inputArray - array of numbers
  6. * @param checkNumber - number to count occurrences of
  7. * @returns number of occurrences
  8. *
  9. */
  10. function countOccurrencesOfNumber(inputArray: Array<number>, checkNumber: number): number {
  11. return inputArray.filter(c => c === checkNumber).length;
  12. }
  13. /**
  14. * Generate a random integer between *1* and `max` (inclusive)
  15. *
  16. * @param max - upper bound of random integer to generate
  17. * @returns random integer
  18. */
  19. function randIntMinOne(max: number): number {
  20. return (1 + Math.floor(Math.random() * Math.floor(max)));
  21. }
  22. interface Dice {
  23. readonly sides: number;
  24. readonly type: string;
  25. }
  26. export enum Outcomes {
  27. Success = "Success",
  28. Fail = "Failure",
  29. CritSuccess = "Critical Success",
  30. CritFail = "Critical Failure",
  31. Other = "Something Else"
  32. }
  33. export interface DiceResult {
  34. total: number;
  35. dice: Array<number>;
  36. olddice?: Array<number>;
  37. reroll?: boolean;
  38. outcome?: Outcomes;
  39. balance?: number;
  40. }
  41. /**
  42. * Simple d10, implements `roll()`
  43. */
  44. class D10 implements Dice {
  45. sides: number = 10;
  46. type: string = "d10"
  47. roll(numberToRoll: number): DiceResult {
  48. let results: DiceResult = { total: 0, dice: [] };
  49. for (let i = 0; i < numberToRoll; i++) {
  50. results.dice.push(randIntMinOne(this.sides));
  51. }
  52. results.total = results.dice.reduce((acc: number, curr: number) => acc + curr);
  53. return results;
  54. }
  55. [util.inspect.custom](): string {
  56. return this.type;
  57. }
  58. /**
  59. * Roll a specified number of dice
  60. *
  61. * @param numberToRoll - integer number of dice to roll
  62. * @returns DiceResult: .total and .dice (individual dice)
  63. *
  64. * @example Roll 4d10
  65. *
  66. * ```
  67. * D10.roll(4)
  68. * { total: 16, dice [ 3, 5, 6, 2] }
  69. *
  70. */
  71. }
  72. export class Asphodice extends D10 {
  73. readonly type: string = "asphodice";
  74. readonly passCrit: number = 10;
  75. readonly failCrit: number = 1;
  76. readonly successCutOff: number = 6; // this or larger
  77. /**
  78. * Re-roll the high dice (ie >= 6) for a chance of failure
  79. * happens on eg 1
  80. *
  81. * @remarks
  82. * Basically we want to remove a die from the original result,
  83. * as long as that die isn't a 10, and is above the
  84. * cutoff. So we filter to get rerollCandidates, find the
  85. * highest value and get the index of that value in the
  86. * original results. We use that to splice() out (remove) that
  87. * value and push the rerolled dice onto the modified array
  88. *
  89. * @param rerollDice
  90. * @returns rerollOutcome
  91. *
  92. * @example Re-roll High Dice on 1
  93. * ```
  94. * rerollHighDice([8, 1, 1, 4, 7, 10]);
  95. * [ 1, 1, 4, 7, 10, 6]
  96. * ```
  97. *
  98. * ## Explanation
  99. *
  100. * - first filter dice to be not 10 and >= 6 (see {@link Asphodice.successCutOff})
  101. * - rerollCandidates are [8, 7]
  102. * - max([8, 7] = 8
  103. * - indexOf(8) = 0
  104. * - rerollResult = 6 (see {@link randIntMinOne()})
  105. * - `splice()` replaced dice out of array = [ 1, 1, 4, 7, 10 ]
  106. * - `push()` new dice = [ 1, 1, 4, 7, 10, 6]
  107. */
  108. rerollHighDice (rerollDice: Array<number>): Array<number> {
  109. let rerollCandidates: Array<number> = rerollDice.filter(die => (die < 10 && die >= this.successCutOff));
  110. let maxValue: number = Math.max(...rerollCandidates);
  111. let maxIndex = rerollDice.indexOf(maxValue);
  112. let rerollResult: number = randIntMinOne(this.sides);
  113. rerollDice.splice(maxIndex, 1);
  114. rerollDice.push(rerollResult);
  115. return rerollDice
  116. }
  117. /**
  118. * Re-roll low dice (ie < 6) for chance of success (ie getting >= 6)
  119. *
  120. * @remarks
  121. *
  122. * Generally happens on a 10.
  123. *
  124. * @see {@link Asphodice.rerollHighDice} for a fuller explanation
  125. * for what process in opposite direction
  126. */
  127. rerollLowDice (rerollDice: Array<number>): Array<number> {
  128. let rerollCandidates: Array<number> = rerollDice.filter(die => (die > 1 && die < this.successCutOff));
  129. let minValue: number = Math.min(...rerollCandidates);
  130. let minIndex = rerollDice.indexOf(minValue);
  131. let rerollResult: number = randIntMinOne(this.sides);
  132. rerollDice.splice(minIndex, 1);
  133. rerollDice.push(rerollResult);
  134. return rerollDice
  135. }
  136. /**
  137. * Used to determine if all dice are above Asphodice.successCutOff
  138. *
  139. * @param checkDice - Array of Dice to test
  140. *
  141. * @see {@link Asphodice.roll} where it is used to determine if
  142. * a re-roll can be skipped
  143. *
  144. * @see {@link allBelowCutOff} also.
  145. */
  146. allAboveCutOff (checkDice: Array<number>): boolean {
  147. // if filtering those *below* cutoff results in empty set
  148. // then all must be above cutoff
  149. return ((checkDice.filter(die => die < this.successCutOff).length == 0));
  150. }
  151. /**
  152. * Used to determine if all dice are below Asphodice.successCutOff
  153. *
  154. * @param checkDice - Array of Dice to test
  155. *
  156. * @see {@link Asphodice.roll} where it is used to determine if
  157. * a re-roll can be skipped
  158. *
  159. * @see {@link allAboveCutOff} also.
  160. */
  161. allBelowCutOff (checkDice: Array<number>): boolean {
  162. // if filtering those *above or equal to* cutoff results in empty set
  163. // then all must be below cutoff
  164. return ((checkDice.filter(die => die >= this.successCutOff).length == 0));
  165. }
  166. /**
  167. * Determine balance of outcomes - ie successes minus failures
  168. *
  169. * @remarks
  170. *
  171. * Success = 1, failure = -1
  172. *
  173. * Particularly for Asphodice, we aren't terribly concerned with the
  174. * numeric sum total of the dice values, we are more concerned with
  175. * the overall outcome (see {@link Outcomes}) and for narrative purposes
  176. * perhaps the balance of outcomes; for example, 1 success may be
  177. * narrated differently than 4 successes.
  178. *
  179. * **Special note**: This ignores crits! **End special note**
  180. *
  181. * TODO: Special consideration of a single die roll (?)
  182. *
  183. * @param resultDice - the final dice after any re-rolls
  184. * @returns Single positive/negative number with balance of roll outcomes
  185. *
  186. * @example Count Outcomes of 5 Dice
  187. * ```
  188. * countOutcomeBalance([ 7, 4, 4, 9, 1 ])
  189. * -1
  190. * ```
  191. *
  192. * - 7 and 9 are above successCutOff → +2
  193. * - 4, 4, 1 are below successCutOff → -3
  194. */
  195. countOutcomeBalance (resultDice: Array<number>): number{
  196. // fun with ternary operators
  197. return resultDice.reduce(
  198. (acc: number, curr: number) =>
  199. { return (curr < this.successCutOff) ? acc - 1 : acc + 1 },
  200. 0);
  201. // PS also good practice to supply initialValue (0)
  202. }
  203. /**
  204. * Determine and return outcome of roll
  205. *
  206. * @remarks
  207. *
  208. * Determines outcome as in "Success", "Failure", etc. Assumes that `resultDice` is
  209. * the 'final' result after any rerolls.
  210. *
  211. * @see {@link:countOutcomeBalance}
  212. */
  213. checkOutcome (resultDice: Array<number>): Outcomes{
  214. // Note: currently, one success = Success, regardless of number of failures
  215. // TODO: Critical failures, once decided with Mao
  216. if (this.allBelowCutOff(resultDice)) {
  217. return Outcomes.Fail;
  218. } else {
  219. return Outcomes.Success;
  220. }
  221. }
  222. /**
  223. * 'Cancel out' reroll dice
  224. *
  225. * @remarks
  226. *
  227. * Helper function.
  228. *
  229. * **In the context of deciding a reroll only**, the reroll dice can
  230. * 'cancel' each other out. The dice themselves remain intact for the
  231. * purposes of determining the final outcome.
  232. *
  233. * @returns Filtered dice
  234. */
  235. cancelRerollDice (resultDice: Array<number>): Array<number> {
  236. // Naive approach for now:
  237. // - count 10s and 1s
  238. // - remove all 10s and 1s from input array
  239. // - re-add 10s/1s after 'cancelling'
  240. let tens = 0;
  241. let ones = 0;
  242. // count
  243. for (let die of resultDice) {
  244. if (die === 10) {
  245. tens++;
  246. } else if (die === 1) {
  247. ones++;
  248. }
  249. }
  250. // remove any reroll dice
  251. let outputDice = resultDice.filter( die => (die != 1 && die != 10) );
  252. let balance = tens - ones;
  253. if (balance < 0) {
  254. // add balance of ones
  255. outputDice.push(...Array(Math.abs(balance)).fill(1));
  256. } else if (balance > 0) {
  257. // add balance of tens
  258. outputDice.push(...Array(Math.abs(balance)).fill(10));
  259. }
  260. return outputDice;
  261. }
  262. /**
  263. * Determines if a reroll is needed
  264. *
  265. * @remarks
  266. *
  267. * In some cases we don't reroll:
  268. * - reroll dice cancel each other out
  269. * - all are above cutoff (=> auto-success -- NB TODO crits)
  270. * - all below cutoff
  271. */
  272. rerollNeeded(resultDice: Array<number>): boolean{
  273. // 'Cancel out' matching re-roll dice
  274. let cancelledDice = this.cancelRerollDice(resultDice);
  275. // 1. if no re-rolls we can finish here
  276. if (!(cancelledDice.includes(this.passCrit) || resultDice.includes(this.failCrit))) {
  277. return false;
  278. }
  279. // count successes and fails
  280. let rerollGood = countOccurrencesOfNumber(cancelledDice, this.passCrit);
  281. let rerollBad = countOccurrencesOfNumber(cancelledDice, this.failCrit);
  282. // 2. only reroll if they don't cancel each other out
  283. if (rerollGood == rerollBad) {
  284. return false;
  285. }
  286. // 3. If all dice are above/below cutoff we don't need to reroll
  287. if (this.allAboveCutOff(cancelledDice) || this.allBelowCutOff(cancelledDice)) {
  288. return false;
  289. }
  290. // We should re-roll
  291. return true;
  292. }
  293. /**
  294. * Roll an Asphodie or Asphodice
  295. *
  296. * @param numToRoll
  297. * @returns a {@link DiceResult} with additional properties:
  298. * - reroll: boolean - whether we rerolled
  299. * - olddice: Array - original roll
  300. * - dice: Array - final outcome
  301. * - balance: number - +/- of outcomes (successes/failures)
  302. */
  303. roll (numToRoll: number): DiceResult {
  304. let results: DiceResult = { total: 0, dice: [] };
  305. // Initial roll
  306. for (let i = 0; i < numToRoll; i++) {
  307. results.dice.push(randIntMinOne(this.sides));
  308. }
  309. // Reroll?
  310. if (this.rerollNeeded(results.dice)) {
  311. // Reminder: arr1 = arr2 is a copy by reference!
  312. let olddice = results.dice.slice();
  313. let rerollGood = countOccurrencesOfNumber(olddice, this.passCrit);
  314. let rerollBad = countOccurrencesOfNumber(olddice, this.failCrit);
  315. if (rerollGood > rerollBad) {
  316. // Re-roll low (<6) dice for chance of success
  317. results.dice = this.rerollLowDice(results.dice);
  318. } else {
  319. // Re-roll high (>=6) dice for chance of failure
  320. results.dice = this.rerollHighDice(results.dice);
  321. }
  322. results.olddice = olddice;
  323. results.reroll = true;
  324. }
  325. results.balance = this.countOutcomeBalance(results.dice);
  326. results.total = results.dice.reduce((acc: number, curr: number) => acc + curr);
  327. // Finally, once we're done with rerolls etc, determine outcome
  328. results.outcome = this.checkOutcome(results.dice);
  329. return results;
  330. }
  331. }