From 22f53e542049664607619893dd1aac4700d4874d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20R=C3=BCmpelein?= Date: Fri, 8 Jan 2021 23:18:01 +0100 Subject: [PATCH] Allow multiple dice and error check. --- spec/support/ds4rolls/executor.spec.ts | 28 ++--- src/module/ds4.ts | 6 +- src/module/rolls/check.ts | 134 +++++++++++++++++++++++ src/module/rolls/ds4roll.ts | 142 ------------------------- src/module/rolls/roll-data.ts | 4 +- src/module/rolls/roll-executor.ts | 18 ++-- src/module/rolls/roll-utils.ts | 4 +- 7 files changed, 161 insertions(+), 175 deletions(-) create mode 100644 src/module/rolls/check.ts delete mode 100644 src/module/rolls/ds4roll.ts diff --git a/spec/support/ds4rolls/executor.spec.ts b/spec/support/ds4rolls/executor.spec.ts index 98978b20..705241a9 100644 --- a/spec/support/ds4rolls/executor.spec.ts +++ b/spec/support/ds4rolls/executor.spec.ts @@ -65,37 +65,37 @@ describe("DS4 Rolls with one die and slaying dice, followup throw.", () => { describe("DS4 Rolls with one die and crit roll modifications.", () => { it("Should do a crit success on `1`.", () => { - expect(rollCheckSingleDie(4, { maxCritSucc: 2, minCritFail: 19 }, [1])).toEqual( + expect(rollCheckSingleDie(4, { maxCritSuccess: 2, minCritFail: 19 }, [1])).toEqual( new RollResult(4, RollResultStatus.CRITICAL_SUCCESS, [1]), ); }); it("Should do a crit success on `maxCritSucc`.", () => { - expect(rollCheckSingleDie(4, { maxCritSucc: 2, minCritFail: 19 }, [2])).toEqual( + expect(rollCheckSingleDie(4, { maxCritSuccess: 2, minCritFail: 19 }, [2])).toEqual( new RollResult(4, RollResultStatus.CRITICAL_SUCCESS, [2]), ); }); it("Should do a success on lower edge case `3`.", () => { - expect(rollCheckSingleDie(4, { maxCritSucc: 2, minCritFail: 19 }, [3])).toEqual( + expect(rollCheckSingleDie(4, { maxCritSuccess: 2, minCritFail: 19 }, [3])).toEqual( new RollResult(3, RollResultStatus.SUCCESS, [3]), ); }); it("Should do a success on upper edge case `18`.", () => { - expect(rollCheckSingleDie(4, { maxCritSucc: 2, minCritFail: 19 }, [18])).toEqual( + expect(rollCheckSingleDie(4, { maxCritSuccess: 2, minCritFail: 19 }, [18])).toEqual( new RollResult(0, RollResultStatus.FAILURE, [18]), ); }); it("Should do a crit fail on `minCritFail`.", () => { - expect(rollCheckSingleDie(4, { maxCritSucc: 2, minCritFail: 19 }, [19])).toEqual( + expect(rollCheckSingleDie(4, { maxCritSuccess: 2, minCritFail: 19 }, [19])).toEqual( new RollResult(0, RollResultStatus.CRITICAL_FAILURE, [19]), ); }); it("Should do a crit fail on `20`", () => { - expect(rollCheckSingleDie(4, { maxCritSucc: 2, minCritFail: 19 }, [20])).toEqual( + expect(rollCheckSingleDie(4, { maxCritSuccess: 2, minCritFail: 19 }, [20])).toEqual( new RollResult(0, RollResultStatus.CRITICAL_FAILURE, [20]), ); }); @@ -171,37 +171,37 @@ describe("DS4 Rolls with multiple dice and no modifiers.", () => { describe("DS4 Rolls with multiple dice and min/max modifiers.", () => { it("Should do a crit fail on `19` for first roll.", () => { - expect(rollCheckMultipleDice(48, { maxCritSucc: 2, minCritFail: 19 }, [19, 15, 6])).toEqual( + expect(rollCheckMultipleDice(48, { maxCritSuccess: 2, minCritFail: 19 }, [19, 15, 6])).toEqual( new RollResult(0, RollResultStatus.CRITICAL_FAILURE, [19, 15, 6]), ); }); it("Should succeed with all rolls crit successes (1 and 2).", () => { - expect(rollCheckMultipleDice(48, { maxCritSucc: 2, minCritFail: 19 }, [2, 1, 2])).toEqual( + expect(rollCheckMultipleDice(48, { maxCritSuccess: 2, minCritFail: 19 }, [2, 1, 2])).toEqual( new RollResult(48, RollResultStatus.CRITICAL_SUCCESS, [2, 1, 2]), ); }); it("Should succeed with the last roll not being sufficient.", () => { - expect(rollCheckMultipleDice(48, { maxCritSucc: 2, minCritFail: 19 }, [15, 15, 15])).toEqual( + expect(rollCheckMultipleDice(48, { maxCritSuccess: 2, minCritFail: 19 }, [15, 15, 15])).toEqual( new RollResult(30, RollResultStatus.SUCCESS, [15, 15, 15]), ); }); it("Should succeed with the last roll a crit success `2`.", () => { - expect(rollCheckMultipleDice(48, { maxCritSucc: 2, minCritFail: 19 }, [15, 15, 2])).toEqual( + expect(rollCheckMultipleDice(48, { maxCritSuccess: 2, minCritFail: 19 }, [15, 15, 2])).toEqual( new RollResult(38, RollResultStatus.SUCCESS, [15, 15, 2]), ); }); it("Should succeed with the last roll being `20` and one crit success '2'.", () => { - expect(rollCheckMultipleDice(48, { maxCritSucc: 2, minCritFail: 19 }, [15, 2, 20])).toEqual( + expect(rollCheckMultipleDice(48, { maxCritSuccess: 2, minCritFail: 19 }, [15, 2, 20])).toEqual( new RollResult(43, RollResultStatus.SUCCESS, [15, 2, 20]), ); }); it("Should succeed with the last roll being `19` and one crit success '2'.", () => { - expect(rollCheckMultipleDice(48, { maxCritSucc: 2, minCritFail: 19 }, [15, 2, 19])).toEqual( + expect(rollCheckMultipleDice(48, { maxCritSuccess: 2, minCritFail: 19 }, [15, 2, 19])).toEqual( new RollResult(42, RollResultStatus.SUCCESS, [15, 2, 19]), ); }); @@ -217,7 +217,7 @@ describe("DS4 Rolls with multiple dice and fail modifiers.", () => { describe("DS4 Rolls with multiple dice and success modifiers.", () => { it("Should succeed with all rolls crit successes (1 and 2).", () => { - expect(rollCheckMultipleDice(48, { maxCritSucc: 2 }, [2, 1, 2])).toEqual( + expect(rollCheckMultipleDice(48, { maxCritSuccess: 2 }, [2, 1, 2])).toEqual( new RollResult(48, RollResultStatus.CRITICAL_SUCCESS, [2, 1, 2]), ); }); @@ -231,7 +231,7 @@ describe("DS4 Rolls with multiple and slaying dice, first throw", () => { }); it("Should issue a critical success, even with resorting dice", () => { - expect(rollCheckMultipleDice(48, { useSlayingDice: true, maxCritSucc: 2 }, [2, 19, 15])).toEqual( + expect(rollCheckMultipleDice(48, { useSlayingDice: true, maxCritSuccess: 2 }, [2, 19, 15])).toEqual( new RollResult(42, RollResultStatus.CRITICAL_SUCCESS, [2, 19, 15]), ); }); diff --git a/src/module/ds4.ts b/src/module/ds4.ts index 1c593ff6..cd5dd1a8 100644 --- a/src/module/ds4.ts +++ b/src/module/ds4.ts @@ -4,7 +4,7 @@ import { DS4ActorSheet } from "./actor/actor-sheet"; import { DS4Item } from "./item/item"; import { DS4ItemSheet } from "./item/item-sheet"; import { DS4 } from "./config"; -import { DS4Roll } from "./rolls/ds4roll"; +import { DS4Check } from "./rolls/check"; Hooks.once("init", async function () { console.log(`DS4 | Initializing the DS4 Game System\n${DS4.ASCII}`); @@ -23,11 +23,11 @@ Hooks.once("init", async function () { CONFIG.Item.entityClass = DS4Item as typeof Item; // Configure Dice - CONFIG.Dice.types = [Die, DS4Roll]; + CONFIG.Dice.types = [Die, DS4Check]; CONFIG.Dice.terms = { c: Coin, d: Die, - s: DS4Roll, + s: DS4Check, }; // Register sheet application classes diff --git a/src/module/rolls/check.ts b/src/module/rolls/check.ts new file mode 100644 index 00000000..4dffa96c --- /dev/null +++ b/src/module/rolls/check.ts @@ -0,0 +1,134 @@ +import { RollResult, RollResultStatus } from "./roll-data"; +import { ds4roll } from "./roll-executor"; + +interface TermData { + number: number; + faces: number; + modifiers: Array; + options: Record; +} + +export class DS4Check extends DiceTerm { + constructor(termData: Partial) { + super({ + number: termData.number, + faces: termData.faces, // should be null + modifiers: termData.modifiers ?? [], + options: termData.options ?? {}, + }); + console.log("This @ constructor: ", termData); + + // Store and parse target value. + const targetValueModifier = this.modifiers.filter((m) => m[0] === "v")[0]; + const tvRgx = new RegExp("v([0-9]+)?"); + const tvMatch = targetValueModifier?.match(tvRgx); + if (tvMatch) { + const [parseTargetValue] = tvMatch.slice(1); + this.targetValue = parseTargetValue ? parseInt(parseTargetValue) : DS4Check.DEFAULT_TARGET_VALUE; + } + + // Store and parse min/max crit + const critModifier = this.modifiers.filter((m) => m[0] === "c")[0]; + const cmRgx = new RegExp("c([0-9]+)?,([0-9]+)?"); + const cmMatch = critModifier?.match(cmRgx); + if (cmMatch) { + const [parseMaxCritSuccess, parseMinCritFailure] = cmMatch.slice(1); + this.maxCritSuccess = parseMaxCritSuccess + ? parseInt(parseMaxCritSuccess) + : DS4Check.DEFAULT_MAX_CRIT_SUCCESS; + this.minCritFailure = parseMinCritFailure + ? parseInt(parseMinCritFailure) + : DS4Check.DEFAULT_MIN_CRIT_FAILURE; + if (this.minCritFailure <= this.maxCritSuccess) + throw new SyntaxError("There's an overlap between Fumbles and Coups"); + } + } + + success = null; + failure = null; + targetValue = DS4Check.DEFAULT_TARGET_VALUE; + minCritFailure = DS4Check.DEFAULT_MIN_CRIT_FAILURE; + maxCritSuccess = DS4Check.DEFAULT_MAX_CRIT_SUCCESS; + + /** + * @override + * @param param0 + * + * @private_notes This gets only called once for the first roll. + */ + roll({ minimize = false, maximize = false } = {}): RollResult { + console.log(`This targetValue @ roll: ${this.targetValue}`); + const rollResult = this.rollWithDifferentBorders({ minimize, maximize }); + this.results.push(rollResult); + if (rollResult.status == RollResultStatus.CRITICAL_SUCCESS) { + this.success = true; + } else if (rollResult.status == RollResultStatus.CRITICAL_FAILURE) { + this.failure = true; + } + return rollResult; + } + + rollWithDifferentBorders({ minimize = false, maximize = false } = {}, slayingDiceRepetition = false): RollResult { + const targetValueToUse = this.targetValue; + if (minimize) { + return new RollResult(0, RollResultStatus.CRITICAL_FAILURE, [20], true); + } else if (maximize) { + const maximizedDice = Array(Math.ceil(targetValueToUse / 20)).fill(1); + return new RollResult(targetValueToUse, RollResultStatus.CRITICAL_SUCCESS, maximizedDice, true); + } else { + return ds4roll(targetValueToUse, { + maxCritSuccess: this.maxCritSuccess, + minCritFail: this.minCritFailure, + slayingDiceRepetition: slayingDiceRepetition, + useSlayingDice: slayingDiceRepetition, + }); + } + } + + /** Term Modifiers */ + noop(): this { + return this; + } + + // DS4 only allows recursive explosions + explode(modifier: string): this { + const rgx = /[xX]/; + const match = modifier.match(rgx); + if (!match) return this; + + this.results = (this.results as Array) + .map((r) => { + const intermedResult = [r]; + + let checked = 0; + while (checked < intermedResult.length) { + const r = (intermedResult as Array)[checked]; + checked++; + if (!r.active) continue; + + if (r.dice[0] <= this.maxCritSuccess) { + r.exploded = true; + const newRoll = this.rollWithDifferentBorders({}, true); + intermedResult.push(newRoll); + } + + if (checked > 1000) throw new Error("Maximum recursion depth for exploding dice roll exceeded"); + } + return intermedResult; + }) + .reduce((acc, cur) => { + return acc.concat(cur); + }, []); + } + + static readonly DEFAULT_TARGET_VALUE = 10; + static readonly DEFAULT_MAX_CRIT_SUCCESS = 1; + static readonly DEFAULT_MIN_CRIT_FAILURE = 20; + // TODO: add to Type declarations + static DENOMINATION = "s"; + static MODIFIERS = { + x: "explode", + c: "noop", // Modifier is consumed in constructor for target value + v: "noop", // Modifier is consumed in constructor for target value + }; +} diff --git a/src/module/rolls/ds4roll.ts b/src/module/rolls/ds4roll.ts deleted file mode 100644 index 2d588b74..00000000 --- a/src/module/rolls/ds4roll.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { RollResult, RollResultStatus } from "./roll-data"; -import { ds4roll } from "./roll-executor"; - -interface TermData { - number: number; - faces: number; - modifiers: Array; - options: Record; -} - -export class DS4Roll extends DiceTerm { - constructor(termData: Partial) { - super({ - number: 1, - faces: termData.faces, - modifiers: termData.modifiers ?? [], - options: termData.options ?? {}, - }); - console.log("This @ constructor: ", termData); - - const targetValueModifier = this.modifiers.filter((m) => m[0] === "v")[0] ?? "v" + DS4Roll.DEFAULT_TARGET_VALUE; - const rgx = new RegExp("v([0-9]+)?"); - const match = targetValueModifier.match(rgx); - if (match) { - const [parseTargetValue] = match.slice(1); - this.targetValue = parseTargetValue ? parseInt(parseTargetValue) : DS4Roll.DEFAULT_TARGET_VALUE; - } - } - - success = null; - failure = null; - number = 1; - targetValue = DS4Roll.DEFAULT_TARGET_VALUE; - - /** - * @override - * @param param0 - * - * @private_notes This gets only called once for the first roll. - */ - roll({ minimize = false, maximize = false } = {}): RollResult { - console.log(`This targetValue @ roll: ${this.targetValue}`); - const rollResult = this.rollWithDifferentBorders(1, 20, { minimize, maximize }); - this.results.push(rollResult); - if (rollResult.status == RollResultStatus.CRITICAL_SUCCESS) { - this.success = true; - } else if (rollResult.status == RollResultStatus.CRITICAL_FAILURE) { - this.failure = true; - } - return rollResult; - } - - rollWithDifferentBorders( - maxCritSuccess: number, - minCritFail: number, - { minimize = false, maximize = false } = {}, - ): RollResult { - const targetValueToUse = this.targetValue; - if (minimize) { - return new RollResult(0, RollResultStatus.CRITICAL_FAILURE, [20], true); - } else if (maximize) { - const numberOfDice = Array(Math.ceil(targetValueToUse / 20)).fill(1); - return new RollResult(targetValueToUse, RollResultStatus.CRITICAL_SUCCESS, numberOfDice, true); - } else { - return ds4roll(targetValueToUse, { maxCritSucc: maxCritSuccess, minCritFail: minCritFail }); - } - } - - /** - * @override - */ - get values(): Array { - return (this.results as Array) - .filter((r) => r.active) - .map((r) => r.dice) - .reduce((acc: Array, cur: Array) => { - return acc.concat(cur); - }, []); - } - - /** Term Modifiers */ - noop(): this { - return this; - } - - crits(modifier: string): this { - const rgx = new RegExp("c([0-9]+)?,([0-9]+)?"); - const match = modifier.match(rgx); - if (!match) return this; - const [parseCritSucc, parsedCritFail] = match.slice(1); - - const maxCritSuccess = parseCritSucc ? parseInt(parseCritSucc) : 1; - const minCritFail = parsedCritFail ? parseInt(parsedCritFail) : 20; - - // noinspection UnnecessaryLocalVariableJS - const newResults: Array = (this.results as Array).map((r) => { - return ds4roll(this.targetValue, { minCritFail: minCritFail, maxCritSucc: maxCritSuccess }, r.dice); - }); - - this.results = newResults; - } - - // DS4 only allows recursive explosions - explode(modifier: string): this { - // There should only ever be a single dice in the results-array at this point! - if (this.results.length != 1) { - console.error(`Skipped explode for term ${this.expression}`); - return this; - } - - const rgx = /[xX]([0-9]+)?/; - const match = modifier.match(rgx); - if (!match) return this; - const [parsedCritSucc] = match.slice(1); - - const maxCritSucc = parsedCritSucc ? parseInt(parsedCritSucc) : 1; - - let checked = 0; - while (checked < this.results.length) { - const r = (this.results as Array)[checked]; - checked++; - if (!r.active) continue; - - if (r.dice[0] <= maxCritSucc) { - r.exploded = true; - const newRoll = this.rollWithDifferentBorders(maxCritSucc, 21); - this.results.push(newRoll); - } - - if (checked > 1000) throw new Error("Maximum recursion depth for exploding dice roll exceeded"); - } - } - - static DEFAULT_TARGET_VALUE = 10; - // TODO: add to Type declarations - static DENOMINATION = "s"; - static MODIFIERS = { - x: "explode", - c: "crits", - v: "noop", // Modifier is consumed in constructor for target value - }; -} diff --git a/src/module/rolls/roll-data.ts b/src/module/rolls/roll-data.ts index ef98b436..964034c9 100644 --- a/src/module/rolls/roll-data.ts +++ b/src/module/rolls/roll-data.ts @@ -1,12 +1,12 @@ export interface RollOptions { - maxCritSucc: number; + maxCritSuccess: number; minCritFail: number; useSlayingDice: boolean; slayingDiceRepetition: boolean; } export class DefaultRollOptions implements RollOptions { - public maxCritSucc = 1; + public maxCritSuccess = 1; public minCritFail = 20; public useSlayingDice = false; public slayingDiceRepetition = false; diff --git a/src/module/rolls/roll-executor.ts b/src/module/rolls/roll-executor.ts index f5464ce3..3efe386f 100644 --- a/src/module/rolls/roll-executor.ts +++ b/src/module/rolls/roll-executor.ts @@ -6,6 +6,7 @@ import { calculateRollResult, isDiceSwapNecessary, isSlayingDiceRepetition, sepa * Performs a roll against a check target number, e.g. for usage in battle, but not for herbs. * @param {number} checkTargetValue the final CTN, including all static modifiers. * @param {Partial} rollOptions optional, final option override that affect the checks outcome, e.g. different values for crits or whether slaying dice are used. + * @param {Array} dice optional, pass already thrown dice that are used instead of rolling new ones. */ export function ds4roll( checkTargetValue: number, @@ -26,12 +27,9 @@ export function ds4roll( * This is not intended for direct usage. Use * {@link ds4roll | the function that is not bound to an amount of Dice} instead. * - * @remarks - * The `provider` is only exposed for testing. - * * @param {number} checkTargetValue - The target value to check against. * @param {RollOptions} rollOptions - Options that affect the checks outcome, e.g. different values for crits or whether slaying dice are used. - * @param {RollProvider} provider - Service providing the various, real dice throws. + * @param {Array} dice optional, pass already thrown dice that are used instead of rolling new ones. * * @returns {RollResult} An object containing detailed information on the roll result. */ @@ -50,7 +48,7 @@ export function rollCheckSingleDie( const usedDice = dice; const rolledDie = usedDice[0]; - if (rolledDie <= usedOptions.maxCritSucc) { + if (rolledDie <= usedOptions.maxCritSuccess) { return new RollResult(checkTargetValue, RollResultStatus.CRITICAL_SUCCESS, usedDice, true); } else if (rolledDie >= usedOptions.minCritFail && !isSlayingDiceRepetition(usedOptions)) { return new RollResult(0, RollResultStatus.CRITICAL_FAILURE, usedDice, true); @@ -70,13 +68,9 @@ export function rollCheckSingleDie( * This is not intended for direct usage. Use * {@link ds4roll | the function that is not bound to an amount of Dice} instead. * - * @remarks - * The `provider` is only exposed for testing. - * - * @param {number} checkTargetValue- - The target value to check against. + * @param {number} targetValue- - The target value to check against. * @param {RollOptions} rollOptions - Options that affect the checks outcome, e.g. different values for crits or whether slaying dice are used. - * @param {RollProvider} provider - Service providing the various, real dice throws. - * @param {Array} dice - Optional array of dice values to consider. + * @param {Array} dice - Optional array of dice values to consider instead of rolling new ones. * * @returns {RollResult} An object containing detailed information on the roll result. */ @@ -118,7 +112,7 @@ export function rollCheckMultipleDice( const evaluationResult = calculateRollResult(sortedRollResults, remainderTargetValue, usedOptions); - if (firstResult <= usedOptions.maxCritSucc) { + if (firstResult <= usedOptions.maxCritSuccess) { return new RollResult(evaluationResult, RollResultStatus.CRITICAL_SUCCESS, usedDice, true); } else { return new RollResult(evaluationResult, RollResultStatus.SUCCESS, usedDice, true); diff --git a/src/module/rolls/roll-utils.ts b/src/module/rolls/roll-utils.ts index 09d618c3..a880a66d 100644 --- a/src/module/rolls/roll-utils.ts +++ b/src/module/rolls/roll-utils.ts @@ -14,7 +14,7 @@ import { RollOptions } from "./roll-data"; */ export function separateCriticalHits(dice: Array, usedOptions: RollOptions): CritsAndNonCrits { const [critSuccesses, otherRolls] = partition(dice, (v: number) => { - return v <= usedOptions.maxCritSucc; + return v <= usedOptions.maxCritSuccess; }).map((a) => a.sort((r1, r2) => r2 - r1)); return [critSuccesses, otherRolls]; @@ -112,7 +112,7 @@ export function calculateRollResult( return rollsAndMaxValues .map(([v, m]) => { - return v <= rollOptions.maxCritSucc ? [m, m] : [v, m]; + return v <= rollOptions.maxCritSuccess ? [m, m] : [v, m]; }) .filter(([v, m]) => v <= m) .map(([v]) => v)