diff --git a/spec/rolls/roll-executor.spec.ts b/spec/rolls/roll-executor.spec.ts deleted file mode 100644 index 85bb9c49..00000000 --- a/spec/rolls/roll-executor.spec.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { RollResult, RollResultStatus } from "../../src/module/rolls/roll-data"; -import { rollCheckMultipleDice, rollCheckSingleDie } from "../../src/module/rolls/roll-executor"; - -describe("DS4 Rolls with one die and no modifications.", () => { - it("Should do a regular success roll.", () => { - expect(rollCheckSingleDie(12, {}, [4])).toEqual(new RollResult(4, RollResultStatus.SUCCESS, [4], true)); - }); - - it("Should do a single success roll on success upper edge case.", () => { - expect(rollCheckSingleDie(4, {}, [4])).toEqual(new RollResult(4, RollResultStatus.SUCCESS, [4], true)); - }); - - it("Should do a single failure roll on lower edge case.", () => { - expect(rollCheckSingleDie(4, {}, [5])).toEqual(new RollResult(0, RollResultStatus.FAILURE, [5], true)); - }); - - it("Should do a single failure roll on upper edge case '19'.", () => { - expect(rollCheckSingleDie(4, {}, [19])).toEqual(new RollResult(0, RollResultStatus.FAILURE, [19])); - }); - - it("Should do a single crit success roll on '1'.", () => { - expect(rollCheckSingleDie(4, {}, [1])).toEqual(new RollResult(4, RollResultStatus.CRITICAL_SUCCESS, [1], true)); - }); - - it("Should do a single crit failure roll on '20'.", () => { - expect(rollCheckSingleDie(4, {}, [20])).toEqual(new RollResult(0, RollResultStatus.CRITICAL_FAILURE, [20])); - }); -}); - -describe("DS4 Rolls with one die and slaying dice, first throw.", () => { - it("Should do a crit success on `1`", () => { - expect(rollCheckSingleDie(4, { useSlayingDice: true }, [1])).toEqual( - new RollResult(4, RollResultStatus.CRITICAL_SUCCESS, [1]), - ); - }); - - it("Should do a crit fail on `20`", () => { - expect(rollCheckSingleDie(4, { useSlayingDice: true }, [20])).toEqual( - new RollResult(0, RollResultStatus.CRITICAL_FAILURE, [20]), - ); - }); -}); - -describe("DS4 Rolls with one die and slaying dice, followup throw.", () => { - it("Should do a crit success on `1`", () => { - expect(rollCheckSingleDie(4, { useSlayingDice: true, slayingDiceRepetition: true }, [1])).toEqual( - new RollResult(4, RollResultStatus.CRITICAL_SUCCESS, [1]), - ); - }); - - it("Should do a regular fail on `20`", () => { - expect(rollCheckSingleDie(4, { useSlayingDice: true, slayingDiceRepetition: true }, [20])).toEqual( - new RollResult(0, RollResultStatus.FAILURE, [20]), - ); - }); - - it("Should do a regular success on `20` with a CTN of 20", () => { - expect(rollCheckSingleDie(20, { useSlayingDice: true, slayingDiceRepetition: true }, [20])).toEqual( - new RollResult(20, RollResultStatus.SUCCESS, [20]), - ); - }); -}); - -describe("DS4 Rolls with one die and crit roll modifications.", () => { - it("Should do a crit success on `1`.", () => { - expect(rollCheckSingleDie(4, { maxCritSuccess: 2, minCritFailure: 19 }, [1])).toEqual( - new RollResult(4, RollResultStatus.CRITICAL_SUCCESS, [1]), - ); - }); - - it("Should do a crit success on `maxCritSucc`.", () => { - expect(rollCheckSingleDie(4, { maxCritSuccess: 2, minCritFailure: 19 }, [2])).toEqual( - new RollResult(4, RollResultStatus.CRITICAL_SUCCESS, [2]), - ); - }); - - it("Should do a success on lower edge case `3`.", () => { - expect(rollCheckSingleDie(4, { maxCritSuccess: 2, minCritFailure: 19 }, [3])).toEqual( - new RollResult(3, RollResultStatus.SUCCESS, [3]), - ); - }); - - it("Should do a success on upper edge case `18`.", () => { - expect(rollCheckSingleDie(4, { maxCritSuccess: 2, minCritFailure: 19 }, [18])).toEqual( - new RollResult(0, RollResultStatus.FAILURE, [18]), - ); - }); - - it("Should do a crit fail on `minCritFailure`.", () => { - expect(rollCheckSingleDie(4, { maxCritSuccess: 2, minCritFailure: 19 }, [19])).toEqual( - new RollResult(0, RollResultStatus.CRITICAL_FAILURE, [19]), - ); - }); - - it("Should do a crit fail on `20`", () => { - expect(rollCheckSingleDie(4, { maxCritSuccess: 2, minCritFailure: 19 }, [20])).toEqual( - new RollResult(0, RollResultStatus.CRITICAL_FAILURE, [20]), - ); - }); -}); - -describe("DS4 Rolls with multiple dice and no modifiers.", () => { - it("Should do a crit fail on `20` for first roll.", () => { - expect(rollCheckMultipleDice(48, {}, [20, 15, 6])).toEqual( - new RollResult(0, RollResultStatus.CRITICAL_FAILURE, [20, 15, 6]), - ); - }); - - it("Should succeed normally with all rolls crit successes.", () => { - expect(rollCheckMultipleDice(48, {}, [1, 1, 1])).toEqual( - new RollResult(48, RollResultStatus.CRITICAL_SUCCESS, [1, 1, 1]), - ); - }); - - it("Should succeed with the last roll not being suficient.", () => { - expect(rollCheckMultipleDice(48, {}, [15, 15, 15])).toEqual( - new RollResult(30, RollResultStatus.SUCCESS, [15, 15, 15]), - ); - }); - - it("Should succeed with the last roll a crit success.", () => { - expect(rollCheckMultipleDice(48, {}, [15, 15, 1])).toEqual( - new RollResult(38, RollResultStatus.SUCCESS, [15, 15, 1]), - ); - }); - - it("Should succeed with the last roll being 20 and one crit success.", () => { - expect(rollCheckMultipleDice(48, {}, [15, 1, 20])).toEqual( - new RollResult(43, RollResultStatus.SUCCESS, [15, 1, 20]), - ); - }); - - it("Should properly maximize throw result with all dice success.", () => { - expect(rollCheckMultipleDice(46, {}, [15, 4, 12])).toEqual( - new RollResult(31, RollResultStatus.SUCCESS, [15, 4, 12]), - ); - }); - - it("Should properly maximize throw result with one dice a failure.", () => { - expect(rollCheckMultipleDice(46, {}, [15, 8, 20])).toEqual( - new RollResult(35, RollResultStatus.SUCCESS, [15, 8, 20]), - ); - }); - - it("Should maximize on 'lowest dice higher than last CTN and crit success thrown'-Edge case, no change required.", () => { - expect(rollCheckMultipleDice(46, {}, [15, 1, 8])).toEqual( - new RollResult(35, RollResultStatus.SUCCESS, [15, 1, 8]), - ); - }); - - it("Should maximize on 2-dice 'lowest dice higher than last CTN and crit success thrown'-Edge case, no change required.", () => { - expect(rollCheckMultipleDice(24, {}, [1, 8])).toEqual( - new RollResult(20, RollResultStatus.CRITICAL_SUCCESS, [1, 8]), - ); - }); - - it("Should maximize on 2-dice 'lowest dice higher than last CTN and crit success thrown'-Edge case, change required.", () => { - expect(rollCheckMultipleDice(38, {}, [1, 19])).toEqual( - new RollResult(37, RollResultStatus.CRITICAL_SUCCESS, [1, 19]), - ); - }); - - it("Should maximize correctly when swapping with more than one crit success", () => { - expect(rollCheckMultipleDice(48, {}, [1, 1, 15])).toEqual( - new RollResult(43, RollResultStatus.CRITICAL_SUCCESS, [1, 1, 15]), - ); - }); -}); - -describe("DS4 Rolls with multiple dice and min/max modifiers.", () => { - it("Should do a crit fail on `19` for first roll.", () => { - expect(rollCheckMultipleDice(48, { maxCritSuccess: 2, minCritFailure: 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, { maxCritSuccess: 2, minCritFailure: 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, { maxCritSuccess: 2, minCritFailure: 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, { maxCritSuccess: 2, minCritFailure: 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, { maxCritSuccess: 2, minCritFailure: 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, { maxCritSuccess: 2, minCritFailure: 19 }, [15, 2, 19])).toEqual( - new RollResult(42, RollResultStatus.SUCCESS, [15, 2, 19]), - ); - }); -}); - -describe("DS4 Rolls with multiple dice and fail modifiers.", () => { - it("Should do a crit fail on `19` for first roll.", () => { - expect(rollCheckMultipleDice(48, { minCritFailure: 19 }, [19, 15, 6])).toEqual( - new RollResult(0, RollResultStatus.CRITICAL_FAILURE, [19, 15, 6]), - ); - }); -}); - -describe("DS4 Rolls with multiple dice and success modifiers.", () => { - it("Should succeed with all rolls crit successes (1 and 2).", () => { - expect(rollCheckMultipleDice(48, { maxCritSuccess: 2 }, [2, 1, 2])).toEqual( - new RollResult(48, RollResultStatus.CRITICAL_SUCCESS, [2, 1, 2]), - ); - }); -}); - -describe("DS4 Rolls with multiple and slaying dice, first throw", () => { - it("Should fail with the first roll being a `20`", () => { - expect(rollCheckMultipleDice(48, { useSlayingDice: true }, [20, 2, 19])).toEqual( - new RollResult(0, RollResultStatus.CRITICAL_FAILURE, [20, 2, 19], true), - ); - }); - - it("Should issue a critical success, even with resorting dice", () => { - expect(rollCheckMultipleDice(48, { useSlayingDice: true, maxCritSuccess: 2 }, [2, 19, 15])).toEqual( - new RollResult(42, RollResultStatus.CRITICAL_SUCCESS, [2, 19, 15]), - ); - }); -}); - -describe("DS4 Rolls with multiple and slaying dice, recurrent throw", () => { - it("Should regularly succeed with the first roll being a `20`", () => { - expect(rollCheckMultipleDice(48, { useSlayingDice: true, slayingDiceRepetition: true }, [20, 2, 19])).toEqual( - new RollResult(41, RollResultStatus.SUCCESS, [20, 2, 19]), - ); - }); -}); diff --git a/spec/rolls/roll-utils.spec.ts b/spec/rolls/roll-utils.spec.ts deleted file mode 100644 index c9db363b..00000000 --- a/spec/rolls/roll-utils.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { isDiceSwapNecessary } from "../../src/module/rolls/roll-utils"; - -describe("Utility function testing if dice swap is necessary", () => { - it("Should not swap if all dice are crit successes.", () => { - expect(isDiceSwapNecessary([[1, 1, 1], []], 9)).toBeFalsy(); - }); - - it("Should not swap if no die is crit success.", () => { - expect(isDiceSwapNecessary([[], [2, 2, 2]], 9)).toBeFalsy(); - }); - - it("Should not swap if all dice are already in use", () => { - expect(isDiceSwapNecessary([[1], [9, 8]], 10)).toBeFalsy(); - }); - - it("Should not swap if result does not get any better", () => { - expect(isDiceSwapNecessary([[1], [8]], 4)).toBeFalsy(); - }); - - it("Should swap if result does get better", () => { - expect(isDiceSwapNecessary([[1], [19]], 18)).toBeTruthy(); - }); -}); diff --git a/src/module/rolls/check-evaluation.ts b/src/module/rolls/check-evaluation.ts index 830e4ca1..dcc0d5cb 100644 --- a/src/module/rolls/check-evaluation.ts +++ b/src/module/rolls/check-evaluation.ts @@ -1,3 +1,22 @@ +export default function evaluateCheck( + dice: number[], + checkTargetNumber: number, + { + maximumCoupResult = 1, + minimumFumbleResult = 20, + canFumble = true, + }: { maximumCoupResult?: number; minimumFumbleResult?: number; canFumble?: boolean } = {}, +): DS4SubCheckResult[] { + const diceWithSubChecks = assignSubChecksToDice(dice, checkTargetNumber, { + maximumCoupResult: maximumCoupResult, + }); + return evaluateDiceWithSubChecks(diceWithSubChecks, { + maximumCoupResult: maximumCoupResult, + minimumFumbleResult: minimumFumbleResult, + canFumble: canFumble, + }); +} + export function assignSubChecksToDice( dice: number[], checkTargetNumber: number, @@ -6,8 +25,9 @@ export function assignSubChecksToDice( }: { maximumCoupResult?: number; } = {}, -): { result: number; checkTargetNumber: number }[] { - const requiredNumberOfDice = Math.ceil(checkTargetNumber / 20); +): DieWithSubCheck[] { + const requiredNumberOfDice = getRequiredNumberOfDice(checkTargetNumber); + if (dice.length !== requiredNumberOfDice || requiredNumberOfDice < 1) { throw new Error(game.i18n.localize("DS4.ErrorInvalidNumberOfDice")); // TODO: add i18n } @@ -55,3 +75,41 @@ function shouldUseCoupForLastSubCheck( dice[indexOfSmallestNonCoup] + checkTargetNumberForLastSubCheck > 20)) ); } + +interface DieWithSubCheck { + result: number; + checkTargetNumber: number; +} + +interface DS4SubCheckResult extends DieWithSubCheck, DiceTerm.Result { + success?: boolean; + failure?: boolean; + count?: number; +} + +function evaluateDiceWithSubChecks( + results: DieWithSubCheck[], + { + maximumCoupResult, + minimumFumbleResult, + canFumble, + }: { maximumCoupResult: number; minimumFumbleResult: number; canFumble: boolean }, +): DS4SubCheckResult[] { + return results.map((dieWithSubCheck, index) => { + const result: DS4SubCheckResult = { + ...dieWithSubCheck, + active: dieWithSubCheck.result <= dieWithSubCheck.checkTargetNumber, + discarded: dieWithSubCheck.result > dieWithSubCheck.checkTargetNumber, + }; + if (result.result <= maximumCoupResult) { + result.success = true; + result.count = result.checkTargetNumber; + } + if (index === 0 && canFumble && result.result >= minimumFumbleResult) result.failure = true; + return result; + }); +} + +export function getRequiredNumberOfDice(checkTargetNumber: number): number { + return Math.ceil(checkTargetNumber / 20); +} diff --git a/src/module/rolls/check.ts b/src/module/rolls/check.ts index 0ff4ef0b..82b684e4 100644 --- a/src/module/rolls/check.ts +++ b/src/module/rolls/check.ts @@ -1,136 +1,138 @@ -import { RollResult, RollResultStatus } from "./roll-data"; -import { ds4roll } from "./roll-executor"; +import evaluateCheck, { getRequiredNumberOfDice } from "./check-evaluation"; -interface TermData { - number: number; - faces: number; - modifiers: Array; - options: Record; +interface DS4CheckTermData extends DiceTerm.TermData { + canFumble: boolean; } /** * Implements DS4 Checks as an emulated "dice throw". * - * @notes - * Be aware that, even though this behaves like one roll, it actually throws several ones internally - * * @example - * - Roll a check against a Check Target Number (CTV) of 18: `/r dsv18` + * - Roll a check against a Check Target Number (CTN) of 18: `/r dsv18` * - Roll a check with multiple dice against a CTN of 34: `/r dsv34` * - Roll a check with a racial ability that makes `2` a coup and `19` a fumble: `/r dsv19c2,19` * - Roll a check with a racial ability that makes `5` a coup and default fumble: `/r dsv19c5` - * - Roll a check with exploding dice: `/r dsv34x` */ export class DS4Check extends DiceTerm { - constructor(termData: Partial) { + constructor({ modifiers = [], options = {}, canFumble = true }: Partial = {}) { super({ - number: termData.number, - faces: termData.faces, // should be null - modifiers: termData.modifiers ?? [], - options: termData.options ?? {}, + faces: 20, + modifiers: modifiers, + options: options, }); - // Store and parse target value. + this.canFumble = canFumble; + + // Parse and store check target number 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; + this.checkTargetNumber = parseTargetValue + ? parseInt(parseTargetValue) + : DS4Check.DEFAULT_CHECK_TARGET_NUMBER; } - // 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) + this.number = getRequiredNumberOfDice(this.checkTargetNumber); + + // Parse and store minimumCoupResult and maximumFumbleResult + const coupFumbleModifier = this.modifiers.filter((m) => m[0] === "c")[0]; + const cfmRgx = new RegExp("c([0-9]+)?,([0-9]+)?"); + const cfmMatch = coupFumbleModifier?.match(cfmRgx); + if (cfmMatch) { + const [parseMaximumCoupResult, parseMinimumFumbleResult] = cfmMatch.slice(1); + this.maximumCoupResult = parseMaximumCoupResult + ? parseInt(parseMaximumCoupResult) + : DS4Check.DEFAULT_MAXIMUM_COUP_RESULT; + this.minimumFumbleResult = parseMinimumFumbleResult + ? parseInt(parseMinimumFumbleResult) + : DS4Check.DEFAULT_MINIMUM_FUMBLE_RESULT; + if (this.minimumFumbleResult <= this.maximumCoupResult) throw new SyntaxError(game.i18n.localize("DS4.ErrorDiceCritOverlap")); } } - success: boolean | null = null; - failure: boolean | null = null; - targetValue = DS4Check.DEFAULT_TARGET_VALUE; - minCritFailure = DS4Check.DEFAULT_MIN_CRIT_FAILURE; - maxCritSuccess = DS4Check.DEFAULT_MAX_CRIT_SUCCESS; + coup: boolean | null = null; + fumble: boolean | null = null; + canFumble: boolean; + checkTargetNumber = DS4Check.DEFAULT_CHECK_TARGET_NUMBER; + minimumFumbleResult = DS4Check.DEFAULT_MINIMUM_FUMBLE_RESULT; + maximumCoupResult = DS4Check.DEFAULT_MAXIMUM_COUP_RESULT; /** * @override */ - roll({ minimize = false, maximize = false } = {}): RollResult { - 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; + get expression(): string { + return `ds${this.modifiers.join("")}`; } - 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, - minCritFailure: this.minCritFailure, - slayingDiceRepetition: slayingDiceRepetition, - useSlayingDice: slayingDiceRepetition, - }); - } + /** + * @override + */ + get total(): number | null { + if (this.fumble) return 0; + return super.total; } - // DS4 only allows recursive explosions - explode(modifier: string): void { - const rgx = /[xX]/; - const match = modifier.match(rgx); - if (!match) return; - - this.results = (this.results as Array) - .map((r) => { - const intermediateResults = [r]; - - let checked = 0; - while (checked < intermediateResults.length) { - const r = intermediateResults[checked]; - checked++; - if (!r.active) continue; - - if (r.dice[0] <= this.maxCritSuccess) { - r.exploded = true; - const newRoll = this.rollWithDifferentBorders({}, true); - intermediateResults.push(newRoll); - } - - if (checked > 1000) throw new Error(game.i18n.localize("DS4.ErrorExplodingRecursionLimitExceeded")); - } - return intermediateResults; - }) - .reduce((acc, cur) => { - return acc.concat(cur); - }, []); + /** + * @override + */ + roll({ minimize = false, maximize = false } = {}): DiceTerm.Result { + // Swap minimize / maximize because in DS4, the best possible roll is a 1 and the worst possible roll is a 20 + return super.roll({ minimize: maximize, maximize: minimize }); } - static readonly DEFAULT_TARGET_VALUE = 10; - static readonly DEFAULT_MAX_CRIT_SUCCESS = 1; - static readonly DEFAULT_MIN_CRIT_FAILURE = 20; + evaluateResults(): void { + const dice = this.results.map((die) => die.result); + const results = evaluateCheck(dice, this.checkTargetNumber, { + maximumCoupResult: this.maximumCoupResult, + minimumFumbleResult: this.minimumFumbleResult, + canFumble: this.canFumble, + }); + this.results = results; + this.coup = results[0].success ?? false; + this.fumble = results[0].failure ?? false; + } + + // // DS4 only allows recursive explosions + // explode(modifier: string): void { + // const rgx = /[xX]/; + // const match = modifier.match(rgx); + // if (!match) return; + + // this.results = (this.results as Array) + // .map((r) => { + // const intermediateResults = [r]; + + // let checked = 0; + // while (checked < intermediateResults.length) { + // const r = intermediateResults[checked]; + // checked++; + // if (!r.active) continue; + + // if (r.dice[0] <= this.maxCritSuccess) { + // r.exploded = true; + // const newRoll = this.rollWithDifferentBorders({}, true); + // intermediateResults.push(newRoll); + // } + + // if (checked > 1000) throw new Error(game.i18n.localize("DS4.ErrorExplodingRecursionLimitExceeded")); + // } + // return intermediateResults; + // }) + // .reduce((acc, cur) => { + // return acc.concat(cur); + // }, []); + // } + + static readonly DEFAULT_CHECK_TARGET_NUMBER = 10; + static readonly DEFAULT_MAXIMUM_COUP_RESULT = 1; + static readonly DEFAULT_MINIMUM_FUMBLE_RESULT = 20; static DENOMINATION = "s"; static MODIFIERS = { - x: "explode", + //x: "explode", c: (): void => undefined, // Modifier is consumed in constructor for crit - v: (): void => undefined, // Modifier is consumed in constructor for target value + v: "evaluateResults", }; } diff --git a/src/module/rolls/roll-data.ts b/src/module/rolls/roll-data.ts deleted file mode 100644 index f4440156..00000000 --- a/src/module/rolls/roll-data.ts +++ /dev/null @@ -1,43 +0,0 @@ -export interface RollOptions { - maxCritSuccess: number; - minCritFailure: number; - useSlayingDice: boolean; - slayingDiceRepetition: boolean; -} - -export class DefaultRollOptions implements RollOptions { - public maxCritSuccess = 1; - public minCritFailure = 20; - public useSlayingDice = false; - public slayingDiceRepetition = false; - - mergeWith(other: Partial): RollOptions { - return { ...this, ...other }; - } -} - -export class RollResult { - constructor( - public result: number, - public status: RollResultStatus, - public dice: Array, - public active: boolean = true, - public exploded: boolean = false, - ) { - if (this.status == RollResultStatus.CRITICAL_FAILURE) { - this.failure = true; - } else if (this.status == RollResultStatus.CRITICAL_SUCCESS) { - this.success = true; - } - } - - public failure: boolean | void = undefined; - public success: boolean | void = undefined; -} - -export enum RollResultStatus { - FAILURE = "FAILURE", - SUCCESS = "SUCCESS", - CRITICAL_FAILURE = "CRITICAL_FAILURE", - CRITICAL_SUCCESS = "CRITICAL_SUCCESS", -} diff --git a/src/module/rolls/roll-executor.ts b/src/module/rolls/roll-executor.ts deleted file mode 100644 index ea84bac8..00000000 --- a/src/module/rolls/roll-executor.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { DefaultRollOptions, RollOptions, RollResult, RollResultStatus } from "./roll-data"; -import { DS4RollProvider } from "./roll-provider"; -import { calculateRollResult, isDiceSwapNecessary, isSlayingDiceRepetition, separateCriticalHits } from "./roll-utils"; - -/** - * Performs a roll against a check target number, e.g. for usage in battle, but not for herbs. - * @param checkTargetValue - the final CTN, including all static modifiers. - * @param rollOptions - optional, final option override that affect the checks outcome, e.g. different values for crits or whether slaying dice are used. - * @param dice - optional, pass already thrown dice that are used instead of rolling new ones. - */ -export function ds4roll( - checkTargetValue: number, - rollOptions: Partial = {}, - dice: Array = [], -): RollResult { - if (checkTargetValue <= 20) { - return rollCheckSingleDie(checkTargetValue, rollOptions, dice); - } else { - return rollCheckMultipleDice(checkTargetValue, rollOptions, dice); - } -} - -/** - * Performs a roll against a single die (CTN less than or equal 20). - * - * @internal - * This is not intended for direct usage. Use - * {@link ds4roll | the function that is not bound to an amount of Dice} instead. - * - * @param checkTargetValue - The target value to check against. - * @param rollOptions - Options that affect the checks outcome, e.g. different values for crits or whether slaying dice are used. - * @param dice - optional, pass already thrown dice that are used instead of rolling new ones. - * - * @returns An object containing detailed information on the roll result. - */ -export function rollCheckSingleDie( - checkTargetValue: number, - rollOptions: Partial, - dice: Array = [], -): RollResult { - const usedOptions = new DefaultRollOptions().mergeWith(rollOptions); - - if (dice.length != 1) { - dice = [new DS4RollProvider().getNextRoll()]; - } - const usedDice = dice; - const rolledDie = usedDice[0]; - - if (rolledDie <= usedOptions.maxCritSuccess) { - return new RollResult(checkTargetValue, RollResultStatus.CRITICAL_SUCCESS, usedDice, true); - } else if (rolledDie >= usedOptions.minCritFailure && !isSlayingDiceRepetition(usedOptions)) { - return new RollResult(0, RollResultStatus.CRITICAL_FAILURE, usedDice, true); - } else { - if (rolledDie <= checkTargetValue) { - return new RollResult(rolledDie, RollResultStatus.SUCCESS, usedDice, true); - } else { - return new RollResult(0, RollResultStatus.FAILURE, usedDice, true); - } - } -} - -/** - * Performs a roll against a multitude of die (CTN greater than 20). - * - * @internal - * This is not intended for direct usage. Use - * {@link ds4roll | the function that is not bound to an amount of Dice} instead. - * - * @param targetValue - The target value to check against. - * @param rollOptions - Options that affect the checks outcome, e.g. different values for crits or whether slaying dice are used. - * @param dice - Optional array of dice values to consider instead of rolling new ones. - * - * @returns An object containing detailed information on the roll result. - */ -export function rollCheckMultipleDice( - targetValue: number, - rollOptions: Partial, - dice: Array = [], -): RollResult { - const usedOptions = new DefaultRollOptions().mergeWith(rollOptions); - const remainderTargetValue = targetValue % 20; - const numberOfDice = Math.ceil(targetValue / 20); - - if (dice.length != numberOfDice) { - dice = new DS4RollProvider().getNextRolls(numberOfDice); - } - const usedDice = dice; - - const firstResult = usedDice[0]; - const slayingDiceRepetition = isSlayingDiceRepetition(usedOptions); - - // Slaying Dice require a different handling. - if (firstResult >= usedOptions.minCritFailure && !slayingDiceRepetition) { - return new RollResult(0, RollResultStatus.CRITICAL_FAILURE, usedDice, true); - } - - const [critSuccesses, otherRolls] = separateCriticalHits(usedDice, usedOptions); - - const swapLastWithCrit: boolean = isDiceSwapNecessary([critSuccesses, otherRolls], remainderTargetValue); - - let sortedRollResults: Array; - - if (swapLastWithCrit) { - const diceToMove = critSuccesses[0]; - const remainingSuccesses = critSuccesses.slice(1); - sortedRollResults = remainingSuccesses.concat(otherRolls).concat([diceToMove]); - } else { - sortedRollResults = critSuccesses.concat(otherRolls); - } - - const evaluationResult = calculateRollResult(sortedRollResults, remainderTargetValue, usedOptions); - - 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-provider.ts b/src/module/rolls/roll-provider.ts deleted file mode 100644 index 86c55606..00000000 --- a/src/module/rolls/roll-provider.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Runtime-implementation of the {@link RollProvider}. - * - * @remarks - * Do not use for tests, it will inevitably fail because the `Roll` class is only provided from declarations, not as implementation! - */ -export class DS4RollProvider implements RollProvider { - getNextRoll(): number { - const rand = CONFIG.Dice.randomUniform(); - return Math.ceil(rand * 20); - } - - getNextRolls(amount: number): Array { - return Array(amount) - .fill(0) - .map(() => this.getNextRoll()); - } -} - -/** - * Provides methods to fetch one or multiple rolls. - */ -export interface RollProvider { - getNextRoll(): number; - getNextRolls(amount: number): Array; -} diff --git a/src/module/rolls/roll-utils.ts b/src/module/rolls/roll-utils.ts deleted file mode 100644 index a3b39b0b..00000000 --- a/src/module/rolls/roll-utils.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { partition, zip } from "../common/utils"; -import { RollOptions } from "./roll-data"; - -/** - * Separates critical hits ("Coups") from throws, that get counted with their regular value. - * - * @internal - * - * @private_remarks - * This uses an internal implementation of a `partition` method. Don't let typescript fool you, it will tell you that a partition method is available for Arrays, but that one's imported globally from foundry's declarations and not available during the test stage! - * - * @param dice - The dice values. - * @param usedOptions - Options that affect the check's behavior. - * @returns A tuple containing two arrays of dice values, the first one containing all critical hits, the second one containing all others. Both arrays are sorted descending by value. - */ -export function separateCriticalHits(dice: Array, usedOptions: RollOptions): CritsAndNonCrits { - const [critSuccesses, otherRolls] = partition(dice, (v: number) => { - return v <= usedOptions.maxCritSuccess; - }).map((a) => a.sort((r1, r2) => r2 - r1)); - - return [critSuccesses, otherRolls]; -} -/** - * Helper type to properly bind combinations of critical and non critical dice. - * @internal - */ -type CritsAndNonCrits = [Array, Array]; - -/** - * Calculates if a critical success should be moved to the last position in order to maximize the check's result. - * - * @example - * With regular dice rolling rules and a check target number of 31, the two dice 1 and 19 can get to a check result of 30. - * This method would be called as follows: - * ```ts - * isDiceSwapNecessary([[1], [19]], 11) - * ``` - * - * @param critsAndNonCrits - The dice values thrown. It is assumed that both critical successes and other rolls are sorted descending. - * @param remainingTargetValue - The target value for the last dice, that is the only one that can be less than 20. - * @returns Bool indicating whether a critical success has to be used as the last dice. - */ -export function isDiceSwapNecessary( - [critSuccesses, otherRolls]: CritsAndNonCrits, - remainingTargetValue: number, -): boolean { - if (critSuccesses.length == 0 || otherRolls.length == 0) { - return false; - } - const amountOfOtherRolls = otherRolls.length; - const lastDice = otherRolls[amountOfOtherRolls - 1]; - if (lastDice <= remainingTargetValue) { - return false; - } - - return lastDice + remainingTargetValue > 20; -} - -/** - * Checks if the options indicate that the current check is emerging from a crit success on a roll with slaying dice. - * - * @internal - * - * @param opts - The roll options to check against - */ -export function isSlayingDiceRepetition(opts: RollOptions): boolean { - return opts.useSlayingDice && opts.slayingDiceRepetition; -} - -/** - * Calculate the check value of an array of dice, assuming the dice should be used in order of occurence. - * - * @internal - * - * @param assignedRollResults - The dice values in the order of usage. - * @param remainderTargetValue - Target value for the last dice (the only one differing from `20`). - * @param rollOptions - Config object containing options that change the way dice results are handled. - * - * @returns {number} The total check value. - */ -export function calculateRollResult( - assignedRollResults: Array, - remainderTargetValue: number, - rollOptions: RollOptions, -): number { - const numberOfDice = assignedRollResults.length; - - const maxResultPerDie: Array = Array(numberOfDice).fill(20); - maxResultPerDie[numberOfDice - 1] = remainderTargetValue; - - const rollsAndMaxValues = zip(assignedRollResults, maxResultPerDie); - - return rollsAndMaxValues - .map(([v, m]) => { - return v <= rollOptions.maxCritSuccess ? [m, m] : [v, m]; - }) - .filter(([v, m]) => v <= m) - .map(([v]) => v) - .reduce((a, b) => a + b); -}