From ea5eee709e0ece76b9019002e7f164f69bd94292 Mon Sep 17 00:00:00 2001 From: Johannes Loher Date: Sat, 13 Mar 2021 02:27:09 +0100 Subject: [PATCH 01/18] Add function to assign sub checks to dice --- package.json | 1 + spec/rolls/check-evaluation.spec.ts | 241 +++++++++++++++++++++++++++ src/module/rolls/check-evaluation.ts | 57 +++++++ 3 files changed, 299 insertions(+) create mode 100644 spec/rolls/check-evaluation.spec.ts create mode 100644 src/module/rolls/check-evaluation.ts diff --git a/package.json b/package.json index ff899987..a27be464 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "lint": "eslint 'src/**/*.ts' --cache", "lint:fix": "eslint 'src/**/*.ts' --cache --fix", "test": "jest", + "test:watch": "jest --watch", "test:ci": "jest --ci --reporters=default --reporters=jest-junit", "format": "prettier --write 'src/**/*.(ts|json|scss)'", "postinstall": "husky install" diff --git a/spec/rolls/check-evaluation.spec.ts b/spec/rolls/check-evaluation.spec.ts new file mode 100644 index 00000000..5c2796c3 --- /dev/null +++ b/spec/rolls/check-evaluation.spec.ts @@ -0,0 +1,241 @@ +import { assignSubChecksToDice } from "../../src/module/rolls/check-evaluation"; + +Object.defineProperty(globalThis, "game", { value: { i18n: { localize: (key: string) => key } } }); + +describe("assignSubChecksToDice with no dice", () => { + it("should throw an error", () => { + expect(() => assignSubChecksToDice([], 10)).toThrow("DS4.ErrorInvalidNumberOfDice"); + }); +}); + +describe("assignSubChecksToDice with more dice than required by the checkTargetNumber", () => { + it("should throw an error", () => { + expect(() => assignSubChecksToDice([10, 10], 10)).toThrow("DS4.ErrorInvalidNumberOfDice"); + }); +}); + +describe("assignSubChecksToDice with less dice than required by the checkTargetNumber", () => { + it("should throw an error", () => { + expect(() => assignSubChecksToDice([10], 21)).toThrow("DS4.ErrorInvalidNumberOfDice"); + }); +}); + +describe("assignSubChecksToDice with a single die", () => { + it("should assign the checkTargetNumber to the single die.", () => { + expect(assignSubChecksToDice([4], 12)).toEqual([{ result: 4, checkTargetNumber: 12 }]); + }); + + it("should assign the checkTargetNumber to the single die on upper edge case.", () => { + expect(assignSubChecksToDice([4], 4)).toEqual([{ result: 4, checkTargetNumber: 4 }]); + }); + + it("should assign the checkTargetNumber to the single die on lower edge case.", () => { + expect(assignSubChecksToDice([5], 4)).toEqual([{ result: 5, checkTargetNumber: 4 }]); + }); + + it("should assign the checkTargetNumber to the single die on upper edge case '19'", () => { + expect(assignSubChecksToDice([19], 4)).toEqual([{ result: 19, checkTargetNumber: 4 }]); + }); + + it("should assign the checkTargetNumber to the single die on '1'", () => { + expect(assignSubChecksToDice([1], 4)).toEqual([{ result: 1, checkTargetNumber: 4 }]); + }); + + it("should assign the checkTargetNumber to the single die on '20'", () => { + expect(assignSubChecksToDice([20], 4)).toEqual([{ result: 20, checkTargetNumber: 4 }]); + }); +}); + +describe("assignSubChecksToDice with a single die and coup modification", () => { + it("should assign the checkTargetNumber to the single die on 'maximumCoupResult'", () => { + expect(assignSubChecksToDice([2], 4, { maximumCoupResult: 2 })).toEqual([{ result: 2, checkTargetNumber: 4 }]); + }); + + it("should assign the checkTargetNumber to the single die on lower edge case '3'", () => { + expect(assignSubChecksToDice([3], 4, { maximumCoupResult: 2 })).toEqual([{ result: 3, checkTargetNumber: 4 }]); + }); +}); + +describe("assignSubChecksToDice with multiple dice", () => { + it("should assign the checkTargetNumber for the last sub check to the lowest non coup, even if the first is '20'.", () => { + expect(assignSubChecksToDice([20, 6, 15], 48)).toEqual([ + { result: 20, checkTargetNumber: 20 }, + { result: 6, checkTargetNumber: 8 }, + { result: 15, checkTargetNumber: 20 }, + ]); + }); + + it("should assign the checkTargetNumber for the last sub check to the first coup if there are only coups.", () => { + expect(assignSubChecksToDice([1, 1, 1], 48)).toEqual([ + { result: 1, checkTargetNumber: 8 }, + { result: 1, checkTargetNumber: 20 }, + { result: 1, checkTargetNumber: 20 }, + ]); + }); + + it("should assign the checkTargetNumber for the last sub check to the first lowest die, even if it is higher than that value.", () => { + expect(assignSubChecksToDice([15, 15, 15], 48)).toEqual([ + { result: 15, checkTargetNumber: 8 }, + { result: 15, checkTargetNumber: 20 }, + { result: 15, checkTargetNumber: 20 }, + ]); + }); + + it("should assign the checkTargetNumber for the last sub check to the first coup if its sum with the lowest non coup is high enough.", () => { + expect(assignSubChecksToDice([15, 15, 1], 48)).toEqual([ + { result: 15, checkTargetNumber: 20 }, + { result: 15, checkTargetNumber: 20 }, + { result: 1, checkTargetNumber: 8 }, + ]); + }); + + it("should assign the checkTargetNumber for the last sub check to the first coup if its sum with the lowest non coup is high enough, even if the last die is '20'.", () => { + expect(assignSubChecksToDice([15, 1, 20], 48)).toEqual([ + { result: 15, checkTargetNumber: 20 }, + { result: 1, checkTargetNumber: 8 }, + { result: 20, checkTargetNumber: 20 }, + ]); + }); + + it("should assign the checkTargetNumber for the last sub check to properly maximize the result when all dice are successes.", () => { + expect(assignSubChecksToDice([15, 4, 12], 46)).toEqual([ + { result: 15, checkTargetNumber: 20 }, + { result: 4, checkTargetNumber: 6 }, + { result: 12, checkTargetNumber: 20 }, + ]); + }); + + it("should assign the checkTargetNumber for the last sub check to properly maximize the result when one dice is a failure.", () => { + expect(assignSubChecksToDice([15, 8, 12], 46)).toEqual([ + { result: 15, checkTargetNumber: 20 }, + { result: 8, checkTargetNumber: 6 }, + { result: 12, checkTargetNumber: 20 }, + ]); + }); + + it("should assign the checkTargetNumber for the last sub check to properly maximize the result on 'lowest dice higher than last check target number and coups thrown'-edge case, coup not used for last sub CTN.", () => { + expect(assignSubChecksToDice([15, 1, 8], 46)).toEqual([ + { result: 15, checkTargetNumber: 20 }, + { result: 1, checkTargetNumber: 20 }, + { result: 8, checkTargetNumber: 6 }, + ]); + }); + + it("should assign the checkTargetNumber for the last sub check to properly maximize the result on 2-dice 'lowest dice higher than last check target number and coups thrown'-edge case, coup not used for last sub CTN.", () => { + expect(assignSubChecksToDice([1, 8], 24)).toEqual([ + { result: 1, checkTargetNumber: 20 }, + { result: 8, checkTargetNumber: 4 }, + ]); + }); + + it("should assign the checkTargetNumber for the last sub check to properly maximize the result on 2-dice 'lowest dice higher than last check target number and coups thrown'-edge case, coup used for last sub CTN.", () => { + expect(assignSubChecksToDice([1, 19], 38)).toEqual([ + { result: 1, checkTargetNumber: 18 }, + { result: 19, checkTargetNumber: 20 }, + ]); + }); + + it("should assign the checkTargetNumber for the last sub check to properly maximize the result when there is more than one coup and a coup is used for the last sub CTN", () => { + expect(assignSubChecksToDice([1, 1, 15], 48)).toEqual([ + { result: 1, checkTargetNumber: 8 }, + { result: 1, checkTargetNumber: 20 }, + { result: 15, checkTargetNumber: 20 }, + ]); + }); +}); + +describe("assignSubChecksToDice with multiple dice and coup modification", () => { + it("should assign the checkTargetNumber for the last sub check to the lowest non coup, even if the first is '19'.", () => { + expect(assignSubChecksToDice([19, 15, 6], 48, { maximumCoupResult: 2 })).toEqual([ + { result: 19, checkTargetNumber: 20 }, + { result: 15, checkTargetNumber: 20 }, + { result: 6, checkTargetNumber: 8 }, + ]); + }); + + it("should assign the checkTargetNumber for the last sub check to the first coup if there are only coups ('1' and '2').", () => { + expect(assignSubChecksToDice([2, 1, 2], 48, { maximumCoupResult: 2 })).toEqual([ + { result: 2, checkTargetNumber: 8 }, + { result: 1, checkTargetNumber: 20 }, + { result: 2, checkTargetNumber: 20 }, + ]); + }); + + it("should assign the checkTargetNumber for the last sub check to the first lowest die, even if it is higher than that value.", () => { + expect(assignSubChecksToDice([15, 15, 15], 48, { maximumCoupResult: 2 })).toEqual([ + { result: 15, checkTargetNumber: 8 }, + { result: 15, checkTargetNumber: 20 }, + { result: 15, checkTargetNumber: 20 }, + ]); + }); + + it("should assign the checkTargetNumber for the last sub check to the first coup ('2') if its sum with the lowest non coup is high enough.", () => { + expect(assignSubChecksToDice([15, 15, 2], 48, { maximumCoupResult: 2 })).toEqual([ + { result: 15, checkTargetNumber: 20 }, + { result: 15, checkTargetNumber: 20 }, + { result: 2, checkTargetNumber: 8 }, + ]); + }); + + it("should assign the checkTargetNumber for the last sub check to the first coup ('2') if its sum with the lowest non coup is high enough, even if the last die is '20'.", () => { + expect(assignSubChecksToDice([15, 2, 20], 48, { maximumCoupResult: 2 })).toEqual([ + { result: 15, checkTargetNumber: 20 }, + { result: 2, checkTargetNumber: 8 }, + { result: 20, checkTargetNumber: 20 }, + ]); + }); + + it("should assign the checkTargetNumber for the last sub check to the first coup ('2') if its sum with the lowest non coup is high enough, even if the last die is '19'.", () => { + expect(assignSubChecksToDice([15, 2, 19], 48, { maximumCoupResult: 2 })).toEqual([ + { result: 15, checkTargetNumber: 20 }, + { result: 2, checkTargetNumber: 8 }, + { result: 19, checkTargetNumber: 20 }, + ]); + }); + + it("should assign the checkTargetNumber for the last sub check to properly maximize the result when all dice are successes.", () => { + expect(assignSubChecksToDice([15, 4, 12], 46, { maximumCoupResult: 2 })).toEqual([ + { result: 15, checkTargetNumber: 20 }, + { result: 4, checkTargetNumber: 6 }, + { result: 12, checkTargetNumber: 20 }, + ]); + }); + + it("should assign the checkTargetNumber for the last sub check to properly maximize the result when one dice is a failure.", () => { + expect(assignSubChecksToDice([15, 8, 12], 46, { maximumCoupResult: 2 })).toEqual([ + { result: 15, checkTargetNumber: 20 }, + { result: 8, checkTargetNumber: 6 }, + { result: 12, checkTargetNumber: 20 }, + ]); + }); + + it("should assign the checkTargetNumber for the last sub check to properly maximize the result on 'lowest dice higher than last check target number and coups ('2') thrown'-edge case, coup not used for last sub CTN.", () => { + expect(assignSubChecksToDice([15, 2, 8], 46, { maximumCoupResult: 2 })).toEqual([ + { result: 15, checkTargetNumber: 20 }, + { result: 2, checkTargetNumber: 20 }, + { result: 8, checkTargetNumber: 6 }, + ]); + }); + + it("should assign the checkTargetNumber for the last sub check to properly maximize the result on 2-dice 'lowest dice higher than last check target number and coups ('2') thrown'-edge case, coup not used for last sub CTN.", () => { + expect(assignSubChecksToDice([2, 8], 24, { maximumCoupResult: 2 })).toEqual([ + { result: 2, checkTargetNumber: 20 }, + { result: 8, checkTargetNumber: 4 }, + ]); + }); + + it("should assign the checkTargetNumber for the last sub check to properly maximize the result on 2-dice 'lowest dice higher than last check target number and coups ('2') thrown'-edge case, coup used for last sub CTN.", () => { + expect(assignSubChecksToDice([2, 19], 38, { maximumCoupResult: 2 })).toEqual([ + { result: 2, checkTargetNumber: 18 }, + { result: 19, checkTargetNumber: 20 }, + ]); + }); + + it("should assign the checkTargetNumber for the last sub check to properly maximize the result when there is more than one coup ('1' and '2') and a coup is used for the last sub CTN", () => { + expect(assignSubChecksToDice([1, 2, 15], 48, { maximumCoupResult: 2 })).toEqual([ + { result: 1, checkTargetNumber: 8 }, + { result: 2, checkTargetNumber: 20 }, + { result: 15, checkTargetNumber: 20 }, + ]); + }); +}); diff --git a/src/module/rolls/check-evaluation.ts b/src/module/rolls/check-evaluation.ts new file mode 100644 index 00000000..830e4ca1 --- /dev/null +++ b/src/module/rolls/check-evaluation.ts @@ -0,0 +1,57 @@ +export function assignSubChecksToDice( + dice: number[], + checkTargetNumber: number, + { + maximumCoupResult = 1, + }: { + maximumCoupResult?: number; + } = {}, +): { result: number; checkTargetNumber: number }[] { + const requiredNumberOfDice = Math.ceil(checkTargetNumber / 20); + if (dice.length !== requiredNumberOfDice || requiredNumberOfDice < 1) { + throw new Error(game.i18n.localize("DS4.ErrorInvalidNumberOfDice")); // TODO: add i18n + } + + const checkTargetNumberForLastSubCheck = checkTargetNumber - 20 * (requiredNumberOfDice - 1); + + const indexOfSmallestNonCoup = findIndexOfSmallestNonCoup(dice, maximumCoupResult); + const indexOfFirstCoup = dice.findIndex((die) => die <= maximumCoupResult); + const indexForLastSubCheck = shouldUseCoupForLastSubCheck( + indexOfSmallestNonCoup, + indexOfFirstCoup, + dice, + checkTargetNumberForLastSubCheck, + ) + ? indexOfFirstCoup + : indexOfSmallestNonCoup; + + return dice.map((die, index) => ({ + result: die, + checkTargetNumber: index === indexForLastSubCheck ? checkTargetNumberForLastSubCheck : 20, + })); +} + +function findIndexOfSmallestNonCoup(dice: number[], maximumCoupResult: number): number { + return dice + .map((die, index) => [die, index]) + .filter((indexedDie) => indexedDie[0] > maximumCoupResult) + .reduce( + (smallestIndexedDie, indexedDie) => + indexedDie[0] < smallestIndexedDie[0] ? indexedDie : smallestIndexedDie, + [Infinity, -1], + )[1]; +} + +function shouldUseCoupForLastSubCheck( + indexOfSmallestNonCoup: number, + indexOfFirstCoup: number, + dice: number[], + checkTargetNumberForLastSubCheck: number, +) { + return ( + indexOfFirstCoup !== -1 && + (indexOfSmallestNonCoup === -1 || + (dice[indexOfSmallestNonCoup] > checkTargetNumberForLastSubCheck && + dice[indexOfSmallestNonCoup] + checkTargetNumberForLastSubCheck > 20)) + ); +} From a542dd1575c8fbb6b2cfb1b851bbf822a1d573f6 Mon Sep 17 00:00:00 2001 From: Johannes Loher Date: Sat, 13 Mar 2021 02:32:28 +0100 Subject: [PATCH 02/18] add punctuation --- spec/rolls/check-evaluation.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/rolls/check-evaluation.spec.ts b/spec/rolls/check-evaluation.spec.ts index 5c2796c3..539a89fd 100644 --- a/spec/rolls/check-evaluation.spec.ts +++ b/spec/rolls/check-evaluation.spec.ts @@ -3,19 +3,19 @@ import { assignSubChecksToDice } from "../../src/module/rolls/check-evaluation"; Object.defineProperty(globalThis, "game", { value: { i18n: { localize: (key: string) => key } } }); describe("assignSubChecksToDice with no dice", () => { - it("should throw an error", () => { + it("should throw an error.", () => { expect(() => assignSubChecksToDice([], 10)).toThrow("DS4.ErrorInvalidNumberOfDice"); }); }); describe("assignSubChecksToDice with more dice than required by the checkTargetNumber", () => { - it("should throw an error", () => { + it("should throw an error.", () => { expect(() => assignSubChecksToDice([10, 10], 10)).toThrow("DS4.ErrorInvalidNumberOfDice"); }); }); describe("assignSubChecksToDice with less dice than required by the checkTargetNumber", () => { - it("should throw an error", () => { + it("should throw an error.", () => { expect(() => assignSubChecksToDice([10], 21)).toThrow("DS4.ErrorInvalidNumberOfDice"); }); }); From 9c1d2f081a3c5442609d6c1eb99bd4586337ff86 Mon Sep 17 00:00:00 2001 From: Johannes Loher Date: Sat, 13 Mar 2021 17:43:48 +0100 Subject: [PATCH 03/18] Make dice viewable seperately in DS4Check --- spec/rolls/roll-executor.spec.ts | 244 --------------------------- spec/rolls/roll-utils.spec.ts | 23 --- src/module/rolls/check-evaluation.ts | 62 ++++++- src/module/rolls/check.ts | 192 ++++++++++----------- src/module/rolls/roll-data.ts | 43 ----- src/module/rolls/roll-executor.ts | 118 ------------- src/module/rolls/roll-provider.ts | 26 --- src/module/rolls/roll-utils.ts | 100 ----------- 8 files changed, 157 insertions(+), 651 deletions(-) delete mode 100644 spec/rolls/roll-executor.spec.ts delete mode 100644 spec/rolls/roll-utils.spec.ts delete mode 100644 src/module/rolls/roll-data.ts delete mode 100644 src/module/rolls/roll-executor.ts delete mode 100644 src/module/rolls/roll-provider.ts delete mode 100644 src/module/rolls/roll-utils.ts 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); -} From bafb770178e067a9f255119eed25220ba7de59bd Mon Sep 17 00:00:00 2001 From: Johannes Loher Date: Sat, 13 Mar 2021 17:47:47 +0100 Subject: [PATCH 04/18] Fix typo --- src/module/rolls/check.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/module/rolls/check.ts b/src/module/rolls/check.ts index 82b684e4..c1fa6b47 100644 --- a/src/module/rolls/check.ts +++ b/src/module/rolls/check.ts @@ -36,7 +36,7 @@ export class DS4Check extends DiceTerm { this.number = getRequiredNumberOfDice(this.checkTargetNumber); - // Parse and store minimumCoupResult and maximumFumbleResult + // Parse and store maximumCoupResult and minimumFumbleResult const coupFumbleModifier = this.modifiers.filter((m) => m[0] === "c")[0]; const cfmRgx = new RegExp("c([0-9]+)?,([0-9]+)?"); const cfmMatch = coupFumbleModifier?.match(cfmRgx); From 98deee1856e5072ea999729fe74063459dde246a Mon Sep 17 00:00:00 2001 From: Johannes Loher Date: Sat, 13 Mar 2021 18:43:23 +0100 Subject: [PATCH 05/18] Add tests for evaluateCheck --- spec/rolls/check-evaluation.spec.ts | 252 +++++++++++++++------------ src/module/rolls/check-evaluation.ts | 12 +- 2 files changed, 142 insertions(+), 122 deletions(-) diff --git a/spec/rolls/check-evaluation.spec.ts b/spec/rolls/check-evaluation.spec.ts index 539a89fd..f240b1f9 100644 --- a/spec/rolls/check-evaluation.spec.ts +++ b/spec/rolls/check-evaluation.spec.ts @@ -1,241 +1,261 @@ -import { assignSubChecksToDice } from "../../src/module/rolls/check-evaluation"; +import evaluateCheck from "../../src/module/rolls/check-evaluation"; Object.defineProperty(globalThis, "game", { value: { i18n: { localize: (key: string) => key } } }); -describe("assignSubChecksToDice with no dice", () => { +describe("evaluateCheck with no dice", () => { it("should throw an error.", () => { - expect(() => assignSubChecksToDice([], 10)).toThrow("DS4.ErrorInvalidNumberOfDice"); + expect(() => evaluateCheck([], 10)).toThrow("DS4.ErrorInvalidNumberOfDice"); }); }); -describe("assignSubChecksToDice with more dice than required by the checkTargetNumber", () => { +describe("evaluateCheck with more dice than required by the checkTargetNumber", () => { it("should throw an error.", () => { - expect(() => assignSubChecksToDice([10, 10], 10)).toThrow("DS4.ErrorInvalidNumberOfDice"); + expect(() => evaluateCheck([10, 10], 10)).toThrow("DS4.ErrorInvalidNumberOfDice"); }); }); -describe("assignSubChecksToDice with less dice than required by the checkTargetNumber", () => { +describe("evaluateCheck with less dice than required by the checkTargetNumber", () => { it("should throw an error.", () => { - expect(() => assignSubChecksToDice([10], 21)).toThrow("DS4.ErrorInvalidNumberOfDice"); + expect(() => evaluateCheck([10], 21)).toThrow("DS4.ErrorInvalidNumberOfDice"); }); }); -describe("assignSubChecksToDice with a single die", () => { - it("should assign the checkTargetNumber to the single die.", () => { - expect(assignSubChecksToDice([4], 12)).toEqual([{ result: 4, checkTargetNumber: 12 }]); +describe("evaluateCheck with a single die", () => { + it("should assign the checkTargetNumber to the single die and be successful.", () => { + expect(evaluateCheck([4], 12)).toEqual([{ result: 4, checkTargetNumber: 12, active: true, discarded: false }]); }); - it("should assign the checkTargetNumber to the single die on upper edge case.", () => { - expect(assignSubChecksToDice([4], 4)).toEqual([{ result: 4, checkTargetNumber: 4 }]); + it("should assign the checkTargetNumber to the single die on upper edge case and be successful.", () => { + expect(evaluateCheck([4], 4)).toEqual([{ result: 4, checkTargetNumber: 4, active: true, discarded: false }]); }); - it("should assign the checkTargetNumber to the single die on lower edge case.", () => { - expect(assignSubChecksToDice([5], 4)).toEqual([{ result: 5, checkTargetNumber: 4 }]); + it("should assign the checkTargetNumber to the single die on lower edge case not be successful.", () => { + expect(evaluateCheck([5], 4)).toEqual([{ result: 5, checkTargetNumber: 4, active: false, discarded: true }]); }); - it("should assign the checkTargetNumber to the single die on upper edge case '19'", () => { - expect(assignSubChecksToDice([19], 4)).toEqual([{ result: 19, checkTargetNumber: 4 }]); + it("should assign the checkTargetNumber to the single die and not be successful on upper edge case '19'", () => { + expect(evaluateCheck([19], 4)).toEqual([{ result: 19, checkTargetNumber: 4, active: false, discarded: true }]); }); - it("should assign the checkTargetNumber to the single die on '1'", () => { - expect(assignSubChecksToDice([1], 4)).toEqual([{ result: 1, checkTargetNumber: 4 }]); + it("should assign the checkTargetNumber to the single die and coup on '1'", () => { + expect(evaluateCheck([1], 4)).toEqual([ + { result: 1, checkTargetNumber: 4, active: true, discarded: false, success: true, count: 4 }, + ]); }); - it("should assign the checkTargetNumber to the single die on '20'", () => { - expect(assignSubChecksToDice([20], 4)).toEqual([{ result: 20, checkTargetNumber: 4 }]); + it("should assign the checkTargetNumber to the single die and fumble on '20'", () => { + expect(evaluateCheck([20], 4)).toEqual([ + { result: 20, checkTargetNumber: 4, active: false, discarded: true, failure: true }, + ]); }); }); -describe("assignSubChecksToDice with a single die and coup modification", () => { - it("should assign the checkTargetNumber to the single die on 'maximumCoupResult'", () => { - expect(assignSubChecksToDice([2], 4, { maximumCoupResult: 2 })).toEqual([{ result: 2, checkTargetNumber: 4 }]); +describe("evaluateCheck with a single die and coup / fumble modification", () => { + it("should assign the checkTargetNumber to the single die and coup on 'maximumCoupResult'", () => { + expect(evaluateCheck([2], 4, { maximumCoupResult: 2 })).toEqual([ + { result: 2, checkTargetNumber: 4, active: true, discarded: false, success: true, count: 4 }, + ]); }); - it("should assign the checkTargetNumber to the single die on lower edge case '3'", () => { - expect(assignSubChecksToDice([3], 4, { maximumCoupResult: 2 })).toEqual([{ result: 3, checkTargetNumber: 4 }]); + it("should assign the checkTargetNumber to the single die and not coup on lower edge case '3'", () => { + expect(evaluateCheck([3], 4, { maximumCoupResult: 2 })).toEqual([ + { result: 3, checkTargetNumber: 4, active: true, discarded: false }, + ]); + }); + + it("should assign the checkTargetNumber to the single die and fumble on 'minimumFUmbleResultResult'", () => { + expect(evaluateCheck([19], 20, { minimumFumbleResult: 19 })).toEqual([ + { result: 19, checkTargetNumber: 20, active: true, discarded: false, failure: true }, + ]); + }); + + it("should assign the checkTargetNumber to the single die and not fumble on upper edge case '18'", () => { + expect(evaluateCheck([18], 20, { minimumFumbleResult: 19 })).toEqual([ + { result: 18, checkTargetNumber: 20, active: true, discarded: false }, + ]); }); }); -describe("assignSubChecksToDice with multiple dice", () => { +describe("evaluateCheck with multiple dice", () => { it("should assign the checkTargetNumber for the last sub check to the lowest non coup, even if the first is '20'.", () => { - expect(assignSubChecksToDice([20, 6, 15], 48)).toEqual([ - { result: 20, checkTargetNumber: 20 }, - { result: 6, checkTargetNumber: 8 }, - { result: 15, checkTargetNumber: 20 }, + expect(evaluateCheck([20, 6, 15], 48)).toEqual([ + { result: 20, checkTargetNumber: 20, active: true, discarded: false, failure: true }, + { result: 6, checkTargetNumber: 8, active: true, discarded: false }, + { result: 15, checkTargetNumber: 20, active: true, discarded: false }, ]); }); it("should assign the checkTargetNumber for the last sub check to the first coup if there are only coups.", () => { - expect(assignSubChecksToDice([1, 1, 1], 48)).toEqual([ - { result: 1, checkTargetNumber: 8 }, - { result: 1, checkTargetNumber: 20 }, - { result: 1, checkTargetNumber: 20 }, + expect(evaluateCheck([1, 1, 1], 48)).toEqual([ + { result: 1, checkTargetNumber: 8, active: true, discarded: false, success: true, count: 8 }, + { result: 1, checkTargetNumber: 20, active: true, discarded: false, success: true, count: 20 }, + { result: 1, checkTargetNumber: 20, active: true, discarded: false, success: true, count: 20 }, ]); }); it("should assign the checkTargetNumber for the last sub check to the first lowest die, even if it is higher than that value.", () => { - expect(assignSubChecksToDice([15, 15, 15], 48)).toEqual([ - { result: 15, checkTargetNumber: 8 }, - { result: 15, checkTargetNumber: 20 }, - { result: 15, checkTargetNumber: 20 }, + expect(evaluateCheck([15, 15, 15], 48)).toEqual([ + { result: 15, checkTargetNumber: 8, active: false, discarded: true }, + { result: 15, checkTargetNumber: 20, active: true, discarded: false }, + { result: 15, checkTargetNumber: 20, active: true, discarded: false }, ]); }); it("should assign the checkTargetNumber for the last sub check to the first coup if its sum with the lowest non coup is high enough.", () => { - expect(assignSubChecksToDice([15, 15, 1], 48)).toEqual([ - { result: 15, checkTargetNumber: 20 }, - { result: 15, checkTargetNumber: 20 }, - { result: 1, checkTargetNumber: 8 }, + expect(evaluateCheck([15, 15, 1], 48)).toEqual([ + { result: 15, checkTargetNumber: 20, active: true, discarded: false }, + { result: 15, checkTargetNumber: 20, active: true, discarded: false }, + { result: 1, checkTargetNumber: 8, active: true, discarded: false, success: true, count: 8 }, ]); }); it("should assign the checkTargetNumber for the last sub check to the first coup if its sum with the lowest non coup is high enough, even if the last die is '20'.", () => { - expect(assignSubChecksToDice([15, 1, 20], 48)).toEqual([ - { result: 15, checkTargetNumber: 20 }, - { result: 1, checkTargetNumber: 8 }, - { result: 20, checkTargetNumber: 20 }, + expect(evaluateCheck([15, 1, 20], 48)).toEqual([ + { result: 15, checkTargetNumber: 20, active: true, discarded: false }, + { result: 1, checkTargetNumber: 8, active: true, discarded: false, success: true, count: 8 }, + { result: 20, checkTargetNumber: 20, active: true, discarded: false }, ]); }); it("should assign the checkTargetNumber for the last sub check to properly maximize the result when all dice are successes.", () => { - expect(assignSubChecksToDice([15, 4, 12], 46)).toEqual([ - { result: 15, checkTargetNumber: 20 }, - { result: 4, checkTargetNumber: 6 }, - { result: 12, checkTargetNumber: 20 }, + expect(evaluateCheck([15, 4, 12], 46)).toEqual([ + { result: 15, checkTargetNumber: 20, active: true, discarded: false }, + { result: 4, checkTargetNumber: 6, active: true, discarded: false }, + { result: 12, checkTargetNumber: 20, active: true, discarded: false }, ]); }); it("should assign the checkTargetNumber for the last sub check to properly maximize the result when one dice is a failure.", () => { - expect(assignSubChecksToDice([15, 8, 12], 46)).toEqual([ - { result: 15, checkTargetNumber: 20 }, - { result: 8, checkTargetNumber: 6 }, - { result: 12, checkTargetNumber: 20 }, + expect(evaluateCheck([15, 8, 12], 46)).toEqual([ + { result: 15, checkTargetNumber: 20, active: true, discarded: false }, + { result: 8, checkTargetNumber: 6, active: false, discarded: true }, + { result: 12, checkTargetNumber: 20, active: true, discarded: false }, ]); }); it("should assign the checkTargetNumber for the last sub check to properly maximize the result on 'lowest dice higher than last check target number and coups thrown'-edge case, coup not used for last sub CTN.", () => { - expect(assignSubChecksToDice([15, 1, 8], 46)).toEqual([ - { result: 15, checkTargetNumber: 20 }, - { result: 1, checkTargetNumber: 20 }, - { result: 8, checkTargetNumber: 6 }, + expect(evaluateCheck([15, 1, 8], 46)).toEqual([ + { result: 15, checkTargetNumber: 20, active: true, discarded: false }, + { result: 1, checkTargetNumber: 20, active: true, discarded: false, success: true, count: 20 }, + { result: 8, checkTargetNumber: 6, active: false, discarded: true }, ]); }); it("should assign the checkTargetNumber for the last sub check to properly maximize the result on 2-dice 'lowest dice higher than last check target number and coups thrown'-edge case, coup not used for last sub CTN.", () => { - expect(assignSubChecksToDice([1, 8], 24)).toEqual([ - { result: 1, checkTargetNumber: 20 }, - { result: 8, checkTargetNumber: 4 }, + expect(evaluateCheck([1, 8], 24)).toEqual([ + { result: 1, checkTargetNumber: 20, active: true, discarded: false, success: true, count: 20 }, + { result: 8, checkTargetNumber: 4, active: false, discarded: true }, ]); }); it("should assign the checkTargetNumber for the last sub check to properly maximize the result on 2-dice 'lowest dice higher than last check target number and coups thrown'-edge case, coup used for last sub CTN.", () => { - expect(assignSubChecksToDice([1, 19], 38)).toEqual([ - { result: 1, checkTargetNumber: 18 }, - { result: 19, checkTargetNumber: 20 }, + expect(evaluateCheck([1, 19], 38)).toEqual([ + { result: 1, checkTargetNumber: 18, active: true, discarded: false, success: true, count: 18 }, + { result: 19, checkTargetNumber: 20, active: true, discarded: false }, ]); }); it("should assign the checkTargetNumber for the last sub check to properly maximize the result when there is more than one coup and a coup is used for the last sub CTN", () => { - expect(assignSubChecksToDice([1, 1, 15], 48)).toEqual([ - { result: 1, checkTargetNumber: 8 }, - { result: 1, checkTargetNumber: 20 }, - { result: 15, checkTargetNumber: 20 }, + expect(evaluateCheck([1, 1, 15], 48)).toEqual([ + { result: 1, checkTargetNumber: 8, active: true, discarded: false, success: true, count: 8 }, + { result: 1, checkTargetNumber: 20, active: true, discarded: false, success: true, count: 20 }, + { result: 15, checkTargetNumber: 20, active: true, discarded: false }, ]); }); }); -describe("assignSubChecksToDice with multiple dice and coup modification", () => { - it("should assign the checkTargetNumber for the last sub check to the lowest non coup, even if the first is '19'.", () => { - expect(assignSubChecksToDice([19, 15, 6], 48, { maximumCoupResult: 2 })).toEqual([ - { result: 19, checkTargetNumber: 20 }, - { result: 15, checkTargetNumber: 20 }, - { result: 6, checkTargetNumber: 8 }, +describe("evaluateCheck with multiple dice and coup / fumble modification", () => { + it("should assign the checkTargetNumber for the last sub check to the lowest non coup and fumble if the first is '19'.", () => { + expect(evaluateCheck([19, 15, 6], 48, { maximumCoupResult: 2, minimumFumbleResult: 19 })).toEqual([ + { result: 19, checkTargetNumber: 20, active: true, discarded: false, failure: true }, + { result: 15, checkTargetNumber: 20, active: true, discarded: false }, + { result: 6, checkTargetNumber: 8, active: true, discarded: false }, ]); }); it("should assign the checkTargetNumber for the last sub check to the first coup if there are only coups ('1' and '2').", () => { - expect(assignSubChecksToDice([2, 1, 2], 48, { maximumCoupResult: 2 })).toEqual([ - { result: 2, checkTargetNumber: 8 }, - { result: 1, checkTargetNumber: 20 }, - { result: 2, checkTargetNumber: 20 }, + expect(evaluateCheck([2, 1, 2], 48, { maximumCoupResult: 2, minimumFumbleResult: 19 })).toEqual([ + { result: 2, checkTargetNumber: 8, active: true, discarded: false, success: true, count: 8 }, + { result: 1, checkTargetNumber: 20, active: true, discarded: false, success: true, count: 20 }, + { result: 2, checkTargetNumber: 20, active: true, discarded: false, success: true, count: 20 }, ]); }); it("should assign the checkTargetNumber for the last sub check to the first lowest die, even if it is higher than that value.", () => { - expect(assignSubChecksToDice([15, 15, 15], 48, { maximumCoupResult: 2 })).toEqual([ - { result: 15, checkTargetNumber: 8 }, - { result: 15, checkTargetNumber: 20 }, - { result: 15, checkTargetNumber: 20 }, + expect(evaluateCheck([15, 15, 15], 48, { maximumCoupResult: 2, minimumFumbleResult: 19 })).toEqual([ + { result: 15, checkTargetNumber: 8, active: false, discarded: true }, + { result: 15, checkTargetNumber: 20, active: true, discarded: false }, + { result: 15, checkTargetNumber: 20, active: true, discarded: false }, ]); }); it("should assign the checkTargetNumber for the last sub check to the first coup ('2') if its sum with the lowest non coup is high enough.", () => { - expect(assignSubChecksToDice([15, 15, 2], 48, { maximumCoupResult: 2 })).toEqual([ - { result: 15, checkTargetNumber: 20 }, - { result: 15, checkTargetNumber: 20 }, - { result: 2, checkTargetNumber: 8 }, + expect(evaluateCheck([15, 15, 2], 48, { maximumCoupResult: 2, minimumFumbleResult: 19 })).toEqual([ + { result: 15, checkTargetNumber: 20, active: true, discarded: false }, + { result: 15, checkTargetNumber: 20, active: true, discarded: false }, + { result: 2, checkTargetNumber: 8, active: true, discarded: false, success: true, count: 8 }, ]); }); it("should assign the checkTargetNumber for the last sub check to the first coup ('2') if its sum with the lowest non coup is high enough, even if the last die is '20'.", () => { - expect(assignSubChecksToDice([15, 2, 20], 48, { maximumCoupResult: 2 })).toEqual([ - { result: 15, checkTargetNumber: 20 }, - { result: 2, checkTargetNumber: 8 }, - { result: 20, checkTargetNumber: 20 }, + expect(evaluateCheck([15, 2, 20], 48, { maximumCoupResult: 2, minimumFumbleResult: 19 })).toEqual([ + { result: 15, checkTargetNumber: 20, active: true, discarded: false }, + { result: 2, checkTargetNumber: 8, active: true, discarded: false, success: true, count: 8 }, + { result: 20, checkTargetNumber: 20, active: true, discarded: false }, ]); }); it("should assign the checkTargetNumber for the last sub check to the first coup ('2') if its sum with the lowest non coup is high enough, even if the last die is '19'.", () => { - expect(assignSubChecksToDice([15, 2, 19], 48, { maximumCoupResult: 2 })).toEqual([ - { result: 15, checkTargetNumber: 20 }, - { result: 2, checkTargetNumber: 8 }, - { result: 19, checkTargetNumber: 20 }, + expect(evaluateCheck([15, 2, 19], 48, { maximumCoupResult: 2, minimumFumbleResult: 19 })).toEqual([ + { result: 15, checkTargetNumber: 20, active: true, discarded: false }, + { result: 2, checkTargetNumber: 8, active: true, discarded: false, success: true, count: 8 }, + { result: 19, checkTargetNumber: 20, active: true, discarded: false }, ]); }); it("should assign the checkTargetNumber for the last sub check to properly maximize the result when all dice are successes.", () => { - expect(assignSubChecksToDice([15, 4, 12], 46, { maximumCoupResult: 2 })).toEqual([ - { result: 15, checkTargetNumber: 20 }, - { result: 4, checkTargetNumber: 6 }, - { result: 12, checkTargetNumber: 20 }, + expect(evaluateCheck([15, 4, 12], 46, { maximumCoupResult: 2, minimumFumbleResult: 19 })).toEqual([ + { result: 15, checkTargetNumber: 20, active: true, discarded: false }, + { result: 4, checkTargetNumber: 6, active: true, discarded: false }, + { result: 12, checkTargetNumber: 20, active: true, discarded: false }, ]); }); it("should assign the checkTargetNumber for the last sub check to properly maximize the result when one dice is a failure.", () => { - expect(assignSubChecksToDice([15, 8, 12], 46, { maximumCoupResult: 2 })).toEqual([ - { result: 15, checkTargetNumber: 20 }, - { result: 8, checkTargetNumber: 6 }, - { result: 12, checkTargetNumber: 20 }, + expect(evaluateCheck([15, 8, 12], 46, { maximumCoupResult: 2, minimumFumbleResult: 19 })).toEqual([ + { result: 15, checkTargetNumber: 20, active: true, discarded: false }, + { result: 8, checkTargetNumber: 6, active: false, discarded: true }, + { result: 12, checkTargetNumber: 20, active: true, discarded: false }, ]); }); it("should assign the checkTargetNumber for the last sub check to properly maximize the result on 'lowest dice higher than last check target number and coups ('2') thrown'-edge case, coup not used for last sub CTN.", () => { - expect(assignSubChecksToDice([15, 2, 8], 46, { maximumCoupResult: 2 })).toEqual([ - { result: 15, checkTargetNumber: 20 }, - { result: 2, checkTargetNumber: 20 }, - { result: 8, checkTargetNumber: 6 }, + expect(evaluateCheck([15, 2, 8], 46, { maximumCoupResult: 2 })).toEqual([ + { result: 15, checkTargetNumber: 20, active: true, discarded: false }, + { result: 2, checkTargetNumber: 20, active: true, discarded: false, success: true, count: 20 }, + { result: 8, checkTargetNumber: 6, active: false, discarded: true }, ]); }); it("should assign the checkTargetNumber for the last sub check to properly maximize the result on 2-dice 'lowest dice higher than last check target number and coups ('2') thrown'-edge case, coup not used for last sub CTN.", () => { - expect(assignSubChecksToDice([2, 8], 24, { maximumCoupResult: 2 })).toEqual([ - { result: 2, checkTargetNumber: 20 }, - { result: 8, checkTargetNumber: 4 }, + expect(evaluateCheck([2, 8], 24, { maximumCoupResult: 2 })).toEqual([ + { result: 2, checkTargetNumber: 20, active: true, discarded: false, success: true, count: 20 }, + { result: 8, checkTargetNumber: 4, active: false, discarded: true }, ]); }); it("should assign the checkTargetNumber for the last sub check to properly maximize the result on 2-dice 'lowest dice higher than last check target number and coups ('2') thrown'-edge case, coup used for last sub CTN.", () => { - expect(assignSubChecksToDice([2, 19], 38, { maximumCoupResult: 2 })).toEqual([ - { result: 2, checkTargetNumber: 18 }, - { result: 19, checkTargetNumber: 20 }, + expect(evaluateCheck([2, 19], 38, { maximumCoupResult: 2 })).toEqual([ + { result: 2, checkTargetNumber: 18, active: true, discarded: false, success: true, count: 18 }, + { result: 19, checkTargetNumber: 20, active: true, discarded: false }, ]); }); it("should assign the checkTargetNumber for the last sub check to properly maximize the result when there is more than one coup ('1' and '2') and a coup is used for the last sub CTN", () => { - expect(assignSubChecksToDice([1, 2, 15], 48, { maximumCoupResult: 2 })).toEqual([ - { result: 1, checkTargetNumber: 8 }, - { result: 2, checkTargetNumber: 20 }, - { result: 15, checkTargetNumber: 20 }, + expect(evaluateCheck([1, 2, 15], 48, { maximumCoupResult: 2 })).toEqual([ + { result: 1, checkTargetNumber: 8, active: true, discarded: false, success: true, count: 8 }, + { result: 2, checkTargetNumber: 20, active: true, discarded: false, success: true, count: 20 }, + { result: 15, checkTargetNumber: 20, active: true, discarded: false }, ]); }); }); diff --git a/src/module/rolls/check-evaluation.ts b/src/module/rolls/check-evaluation.ts index dcc0d5cb..df3f427e 100644 --- a/src/module/rolls/check-evaluation.ts +++ b/src/module/rolls/check-evaluation.ts @@ -17,7 +17,12 @@ export default function evaluateCheck( }); } -export function assignSubChecksToDice( +interface DieWithSubCheck { + result: number; + checkTargetNumber: number; +} + +function assignSubChecksToDice( dice: number[], checkTargetNumber: number, { @@ -76,11 +81,6 @@ function shouldUseCoupForLastSubCheck( ); } -interface DieWithSubCheck { - result: number; - checkTargetNumber: number; -} - interface DS4SubCheckResult extends DieWithSubCheck, DiceTerm.Result { success?: boolean; failure?: boolean; From 7e5a912cf07abe408c9ddfc871f0a78f5c5d77cd Mon Sep 17 00:00:00 2001 From: Johannes Loher Date: Sat, 13 Mar 2021 18:46:25 +0100 Subject: [PATCH 06/18] remove commented slaying dice code --- src/module/rolls/check.ts | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/src/module/rolls/check.ts b/src/module/rolls/check.ts index c1fa6b47..90284037 100644 --- a/src/module/rolls/check.ts +++ b/src/module/rolls/check.ts @@ -95,43 +95,11 @@ export class DS4Check extends DiceTerm { 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", c: (): void => undefined, // Modifier is consumed in constructor for crit v: "evaluateResults", }; From 5d3d5bc5337bb98f1f8161749fd0897d11e1c748 Mon Sep 17 00:00:00 2001 From: Johannes Loher Date: Sat, 13 Mar 2021 18:50:39 +0100 Subject: [PATCH 07/18] Rename some variables --- src/module/rolls/check.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/module/rolls/check.ts b/src/module/rolls/check.ts index 90284037..a3c3b1e9 100644 --- a/src/module/rolls/check.ts +++ b/src/module/rolls/check.ts @@ -24,13 +24,13 @@ export class DS4Check extends DiceTerm { 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.checkTargetNumber = parseTargetValue - ? parseInt(parseTargetValue) + const checkTargetNumberModifier = this.modifiers.filter((m) => m[0] === "v")[0]; + const ctnRgx = new RegExp("v([0-9]+)?"); + const ctnMatch = checkTargetNumberModifier?.match(ctnRgx); + if (ctnMatch) { + const [parseCheckTargetNumber] = ctnMatch.slice(1); + this.checkTargetNumber = parseCheckTargetNumber + ? parseInt(parseCheckTargetNumber) : DS4Check.DEFAULT_CHECK_TARGET_NUMBER; } From 0b98925aebf08651237cebbe80fbbc115040abd5 Mon Sep 17 00:00:00 2001 From: Johannes Loher Date: Sat, 13 Mar 2021 18:53:26 +0100 Subject: [PATCH 08/18] Rename an interface --- src/module/rolls/check-evaluation.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/module/rolls/check-evaluation.ts b/src/module/rolls/check-evaluation.ts index df3f427e..8ad4d92f 100644 --- a/src/module/rolls/check-evaluation.ts +++ b/src/module/rolls/check-evaluation.ts @@ -6,7 +6,7 @@ export default function evaluateCheck( minimumFumbleResult = 20, canFumble = true, }: { maximumCoupResult?: number; minimumFumbleResult?: number; canFumble?: boolean } = {}, -): DS4SubCheckResult[] { +): SubCheckResult[] { const diceWithSubChecks = assignSubChecksToDice(dice, checkTargetNumber, { maximumCoupResult: maximumCoupResult, }); @@ -81,7 +81,7 @@ function shouldUseCoupForLastSubCheck( ); } -interface DS4SubCheckResult extends DieWithSubCheck, DiceTerm.Result { +interface SubCheckResult extends DieWithSubCheck, DiceTerm.Result { success?: boolean; failure?: boolean; count?: number; @@ -94,9 +94,9 @@ function evaluateDiceWithSubChecks( minimumFumbleResult, canFumble, }: { maximumCoupResult: number; minimumFumbleResult: number; canFumble: boolean }, -): DS4SubCheckResult[] { +): SubCheckResult[] { return results.map((dieWithSubCheck, index) => { - const result: DS4SubCheckResult = { + const result: SubCheckResult = { ...dieWithSubCheck, active: dieWithSubCheck.result <= dieWithSubCheck.checkTargetNumber, discarded: dieWithSubCheck.result > dieWithSubCheck.checkTargetNumber, From e1d376057c46dd4d4923b12597f498004a4a8f22 Mon Sep 17 00:00:00 2001 From: Johannes Loher Date: Sat, 13 Mar 2021 19:34:05 +0100 Subject: [PATCH 09/18] FIx creating DS4Check from data / results --- src/module/rolls/check.ts | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/module/rolls/check.ts b/src/module/rolls/check.ts index a3c3b1e9..e8c518c9 100644 --- a/src/module/rolls/check.ts +++ b/src/module/rolls/check.ts @@ -75,6 +75,15 @@ export class DS4Check extends DiceTerm { return super.total; } + /** + * @override + */ + evaluate({ minimize = false, maximize = false } = {}): this { + super.evaluate({ minimize, maximize }); + this.evaluateResults(); + return this; + } + /** * @override */ @@ -95,12 +104,23 @@ export class DS4Check extends DiceTerm { this.fumble = results[0].failure ?? false; } + /** + * @override + */ + static fromResults(options: Partial, results: DiceTerm.Result[]): DS4Check { + const term = new this(options); + term.results = results; + term.evaluateResults(); + term._evaluated = true; + return term; + } + 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 = { - c: (): void => undefined, // Modifier is consumed in constructor for crit - v: "evaluateResults", + c: (): void => undefined, // Modifier is consumed in constructor for maximumCoupResult / minimumFumbleResult + v: (): void => undefined, // Modifier is consumed in constructor for checkTargetNumber }; } From eeb1aa61f4baf784a08d246ac516f1045ea5e388 Mon Sep 17 00:00:00 2001 From: Johannes Loher Date: Sat, 13 Mar 2021 20:58:59 +0100 Subject: [PATCH 10/18] Use coups, even if they are higher than the corresponding CTN --- spec/rolls/check-evaluation.spec.ts | 8 ++++++++ src/module/rolls/check-evaluation.ts | 2 ++ 2 files changed, 10 insertions(+) diff --git a/spec/rolls/check-evaluation.spec.ts b/spec/rolls/check-evaluation.spec.ts index f240b1f9..4df65e2c 100644 --- a/spec/rolls/check-evaluation.spec.ts +++ b/spec/rolls/check-evaluation.spec.ts @@ -258,4 +258,12 @@ describe("evaluateCheck with multiple dice and coup / fumble modification", () = { result: 15, checkTargetNumber: 20, active: true, discarded: false }, ]); }); + + it("should use all the dice if they are coups, even if they are higher than the checkTargetNumber", () => { + expect(evaluateCheck([18, 19, 17], 48, { maximumCoupResult: 19 })).toEqual([ + { result: 18, checkTargetNumber: 8, active: true, discarded: false, success: true, count: 8 }, + { result: 19, checkTargetNumber: 20, active: true, discarded: false, success: true, count: 20 }, + { result: 17, checkTargetNumber: 20, active: true, discarded: false, success: true, count: 20 }, + ]); + }); }); diff --git a/src/module/rolls/check-evaluation.ts b/src/module/rolls/check-evaluation.ts index 8ad4d92f..fa97e822 100644 --- a/src/module/rolls/check-evaluation.ts +++ b/src/module/rolls/check-evaluation.ts @@ -104,6 +104,8 @@ function evaluateDiceWithSubChecks( if (result.result <= maximumCoupResult) { result.success = true; result.count = result.checkTargetNumber; + result.active = true; + result.discarded = false; } if (index === 0 && canFumble && result.result >= minimumFumbleResult) result.failure = true; return result; From 7f973e7de82f14df92cc9e865a37ab95a6e8b293 Mon Sep 17 00:00:00 2001 From: Johannes Loher Date: Sat, 13 Mar 2021 22:08:04 +0100 Subject: [PATCH 11/18] Add slying dice modifier to DicePool --- src/module/ds4.ts | 17 +++++++------ src/module/rolls/check.ts | 26 ++++++++++---------- src/module/rolls/slaying-dice-modifier.ts | 29 +++++++++++++++++++++++ 3 files changed, 53 insertions(+), 19 deletions(-) create mode 100644 src/module/rolls/slaying-dice-modifier.ts diff --git a/src/module/ds4.ts b/src/module/ds4.ts index 67d03724..f64e05c0 100644 --- a/src/module/ds4.ts +++ b/src/module/ds4.ts @@ -1,15 +1,16 @@ import { DS4Actor } from "./actor/actor"; -import { DS4Item } from "./item/item"; -import { DS4ItemSheet } from "./item/item-sheet"; -import { DS4 } from "./config"; -import { DS4Check } from "./rolls/check"; import { DS4CharacterActorSheet } from "./actor/sheets/character-sheet"; import { DS4CreatureActorSheet } from "./actor/sheets/creature-sheet"; -import { createCheckRoll } from "./rolls/check-factory"; -import { registerSystemSettings } from "./settings"; -import { migration } from "./migrations"; +import { DS4 } from "./config"; import registerHandlebarsHelpers from "./handlebars/handlebars-helpers"; import registerHandlebarsPartials from "./handlebars/handlebars-partials"; +import { DS4Item } from "./item/item"; +import { DS4ItemSheet } from "./item/item-sheet"; +import { migration } from "./migrations"; +import { DS4Check } from "./rolls/check"; +import { createCheckRoll } from "./rolls/check-factory"; +import registerSlayingDiceModifier from "./rolls/slaying-dice-modifier"; +import { registerSystemSettings } from "./settings"; Hooks.once("init", async () => { console.log(`DS4 | Initializing the DS4 Game System\n${DS4.ASCII}`); @@ -33,6 +34,8 @@ Hooks.once("init", async () => { CONFIG.Dice.types.push(DS4Check); CONFIG.Dice.terms.s = DS4Check; + registerSlayingDiceModifier(); + registerSystemSettings(); Actors.unregisterSheet("core", ActorSheet); diff --git a/src/module/rolls/check.ts b/src/module/rolls/check.ts index e8c518c9..ea24c4e5 100644 --- a/src/module/rolls/check.ts +++ b/src/module/rolls/check.ts @@ -1,28 +1,22 @@ import evaluateCheck, { getRequiredNumberOfDice } from "./check-evaluation"; -interface DS4CheckTermData extends DiceTerm.TermData { - canFumble: boolean; -} - /** * Implements DS4 Checks as an emulated "dice throw". * * @example * - 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 `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` */ export class DS4Check extends DiceTerm { - constructor({ modifiers = [], options = {}, canFumble = true }: Partial = {}) { + constructor({ modifiers = [], options }: Partial = {}) { super({ faces: 20, modifiers: modifiers, options: options, }); - this.canFumble = canFumble; - // Parse and store check target number const checkTargetNumberModifier = this.modifiers.filter((m) => m[0] === "v")[0]; const ctnRgx = new RegExp("v([0-9]+)?"); @@ -38,10 +32,11 @@ export class DS4Check extends DiceTerm { // Parse and store maximumCoupResult and minimumFumbleResult const coupFumbleModifier = this.modifiers.filter((m) => m[0] === "c")[0]; - const cfmRgx = new RegExp("c([0-9]+)?,([0-9]+)?"); + const cfmRgx = new RegExp("c([0-9]+)?(:([0-9]+))?"); const cfmMatch = coupFumbleModifier?.match(cfmRgx); if (cfmMatch) { - const [parseMaximumCoupResult, parseMinimumFumbleResult] = cfmMatch.slice(1); + const parseMaximumCoupResult = cfmMatch[1]; + const parseMinimumFumbleResult = cfmMatch[3]; this.maximumCoupResult = parseMaximumCoupResult ? parseInt(parseMaximumCoupResult) : DS4Check.DEFAULT_MAXIMUM_COUP_RESULT; @@ -51,11 +46,17 @@ export class DS4Check extends DiceTerm { if (this.minimumFumbleResult <= this.maximumCoupResult) throw new SyntaxError(game.i18n.localize("DS4.ErrorDiceCritOverlap")); } + + // Parse and store no fumble + const noFumbleModifier = this.modifiers.filter((m) => m[0] === "n")[0]; + if (noFumbleModifier) { + this.canFumble = false; + } } coup: boolean | null = null; fumble: boolean | null = null; - canFumble: boolean; + canFumble = true; checkTargetNumber = DS4Check.DEFAULT_CHECK_TARGET_NUMBER; minimumFumbleResult = DS4Check.DEFAULT_MINIMUM_FUMBLE_RESULT; maximumCoupResult = DS4Check.DEFAULT_MAXIMUM_COUP_RESULT; @@ -107,7 +108,7 @@ export class DS4Check extends DiceTerm { /** * @override */ - static fromResults(options: Partial, results: DiceTerm.Result[]): DS4Check { + static fromResults(options: Partial, results: DiceTerm.Result[]): DS4Check { const term = new this(options); term.results = results; term.evaluateResults(); @@ -122,5 +123,6 @@ export class DS4Check extends DiceTerm { static MODIFIERS = { c: (): void => undefined, // Modifier is consumed in constructor for maximumCoupResult / minimumFumbleResult v: (): void => undefined, // Modifier is consumed in constructor for checkTargetNumber + n: (): void => undefined, // Modifier is consumed in constructor for canFumble }; } diff --git a/src/module/rolls/slaying-dice-modifier.ts b/src/module/rolls/slaying-dice-modifier.ts new file mode 100644 index 00000000..dbb4176a --- /dev/null +++ b/src/module/rolls/slaying-dice-modifier.ts @@ -0,0 +1,29 @@ +import { DS4Check } from "./check"; + +export default function registerSlayingDiceModifier(): void { + // TODO(types): Adjust types to allow extension of DiceTerm.MODIFIERS + // eslint-disable-next-line + // @ts-ignore + DicePool.MODIFIERS.x = slay; + DicePool.POOL_REGEX = /^{([^}]+)}([A-z]([A-z0-9<=>]+)?)?$/; +} + +function slay(this: DicePool, modifier: string): void { + const rgx = /[xX]/; + const match = modifier.match(rgx); + if (!match || !this.rolls) return; + + let checked = 0; + while (checked < (this.dice.length ?? 0)) { + const diceTerm = this.dice[checked]; + checked++; + if (diceTerm instanceof DS4Check && diceTerm.coup) { + const formula = `dsv${diceTerm.checkTargetNumber}c${diceTerm.maximumCoupResult}:${diceTerm.minimumFumbleResult}n`; + const additionalRoll = Roll.create(formula).evaluate(); + + this.rolls.push(additionalRoll); + this.results.push({ result: additionalRoll.total ?? 0, active: true }); + } + if (checked > 1000) throw new Error(game.i18n.localize("DS4.ErrorExplodingRecursionLimitExceeded")); + } +} From 18e6c31b5c14577eaa50591b53e65062688506c4 Mon Sep 17 00:00:00 2001 From: Johannes Loher Date: Sat, 13 Mar 2021 22:15:58 +0100 Subject: [PATCH 12/18] Fix slying dice usage in CheckFactory --- src/module/rolls/check-factory.ts | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/module/rolls/check-factory.ts b/src/module/rolls/check-factory.ts index 2b3556b7..7e4997b1 100644 --- a/src/module/rolls/check-factory.ts +++ b/src/module/rolls/check-factory.ts @@ -36,12 +36,8 @@ class CheckFactory { async execute(): Promise { const rollCls = CONFIG.Dice.rolls[0]; - const formula = [ - "ds", - this.createTargetValueTerm(), - this.createCritTerm(), - this.createSlayingDiceTerm(), - ].filterJoin(""); + const innerFormula = ["ds", this.createTargetValueTerm(), this.createCritTerm()].filterJoin(""); + const formula = this.checkOptions.useSlayingDice ? `{${innerFormula}}x` : innerFormula; const roll = new rollCls(formula); const rollModeTemplate = this.checkOptions.rollMode; @@ -62,15 +58,11 @@ class CheckFactory { const maxCritRequired = this.checkOptions.maxCritSuccess !== defaultCheckOptions.maxCritSuccess; if (minCritRequired || maxCritRequired) { - return "c" + (this.checkOptions.maxCritSuccess ?? "") + "," + (this.checkOptions.minCritFailure ?? ""); + return "c" + (this.checkOptions.maxCritSuccess ?? "") + ":" + (this.checkOptions.minCritFailure ?? ""); } else { return null; } } - - createSlayingDiceTerm(): string | null { - return this.checkOptions.useSlayingDice ? "x" : null; - } } /** From 3ea07a2379bfa407f8c9c75f010c9d31529127d9 Mon Sep 17 00:00:00 2001 From: Johannes Loher Date: Sat, 13 Mar 2021 22:22:28 +0100 Subject: [PATCH 13/18] Add missing i18n --- src/lang/de.json | 5 +++-- src/lang/en.json | 5 +++-- src/module/rolls/check-evaluation.ts | 2 +- src/module/rolls/check.ts | 2 +- src/module/rolls/slaying-dice-modifier.ts | 2 +- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/lang/de.json b/src/lang/de.json index 3e2c0ae9..1f5fc6dd 100644 --- a/src/lang/de.json +++ b/src/lang/de.json @@ -185,8 +185,9 @@ "DS4.CreatureBaseInfoDescription": "Beschreibung", "DS4.WarningManageActiveEffectOnOwnedItem": "Das Verwalten von aktiven Effekten innerhalb eines besessen Items wird derzeit nicht unterstützt und wird in einem nachfolgenden Update hinzugefügt.", "DS4.WarningActorCannotOwnItem": "Der Aktor '{actorName}' vom Typ '{actorType}' kann das Item '{itemName}' vom Typ '{itemType}' nicht besitzen.", - "DS4.ErrorDiceCritOverlap": "Es gibt eine Überlappung zwischen Patzern und Immersiegen.", - "DS4.ErrorExplodingRecursionLimitExceeded": "Die maximale Rekursionstiefe für slayende Würfelwürfe wurde überschritten.", + "DS4.ErrorDiceCoupFumbleOverlap": "Es gibt eine Überlappung zwischen Patzern und Immersiegen.", + "DS4.ErrorSlayingDiceRecursionLimitExceeded": "Die maximale Rekursionstiefe für slayende Würfelwürfe wurde überschritten.", + "DS4.ErrorInvalidNumberOfDice": "Ungültige Anzahl an Würfeln.", "DS4.ErrorDuringMigration": "Fehler während der Aktualisierung des DS4 Systems von Migrationsversion {currentVersion} auf {targetVersion}. Der Fehler trat während der Ausführung des Migrationsskripts mit der Version {migrationVersion} auf. Spätere Migrationsskripte wurden nicht ausgeführt. Mehr Details finden Sie in der Entwicklerkonsole (F12).", "DS4.ErrorCannotRollUnownedItem": "Für das Item '{name}' ({id}) kann nicht gewürfelt werden, da es keinem Aktor gehört.", "DS4.ErrorRollingForItemTypeNotPossible": "Würfeln ist für Items vom Typ '{type}' nicht möglich.", diff --git a/src/lang/en.json b/src/lang/en.json index 056091c6..c99f0e60 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -185,8 +185,9 @@ "DS4.CreatureBaseInfoDescription": "Description", "DS4.WarningManageActiveEffectOnOwnedItem": "Managing Active Effects within an Owned Item is not currently supported and will be added in a subsequent update.", "DS4.WarningActorCannotOwnItem": "The actor '{actorName}' of type '{actorType}' cannot own the item '{itemName}' of type '{itemType}'.", - "DS4.ErrorDiceCritOverlap": "There's an overlap between Fumbles and Coups", - "DS4.ErrorExplodingRecursionLimitExceeded": "Maximum recursion depth for exploding dice roll exceeded", + "DS4.ErrorDiceCoupFumbleOverlap": "There is an overlap between Fumbles and Coups.", + "DS4.ErrorSlayingDiceRecursionLimitExceeded": "Maximum recursion depth for slaying dice roll exceeded.", + "DS4.ErrorInvalidNumberOfDice": "Invalid number of dice.", "DS4.ErrorDuringMigration": "Error while migrating DS4 system from migration version {currentVersion} to {targetVersion}. The error occurred during execution of migration script with version {migrationVersion}. Later migrations have not been executed. For more details, please look at the development console (F12).", "DS4.ErrorCannotRollUnownedItem": "Rolling for item '{name}' ({id})is not possible because it is not owned.", "DS4.ErrorRollingForItemTypeNotPossible": "Rolling is not possible for items of type '{type}'.", diff --git a/src/module/rolls/check-evaluation.ts b/src/module/rolls/check-evaluation.ts index fa97e822..442ecf3d 100644 --- a/src/module/rolls/check-evaluation.ts +++ b/src/module/rolls/check-evaluation.ts @@ -34,7 +34,7 @@ function assignSubChecksToDice( const requiredNumberOfDice = getRequiredNumberOfDice(checkTargetNumber); if (dice.length !== requiredNumberOfDice || requiredNumberOfDice < 1) { - throw new Error(game.i18n.localize("DS4.ErrorInvalidNumberOfDice")); // TODO: add i18n + throw new Error(game.i18n.localize("DS4.ErrorInvalidNumberOfDice")); } const checkTargetNumberForLastSubCheck = checkTargetNumber - 20 * (requiredNumberOfDice - 1); diff --git a/src/module/rolls/check.ts b/src/module/rolls/check.ts index ea24c4e5..58e3894c 100644 --- a/src/module/rolls/check.ts +++ b/src/module/rolls/check.ts @@ -44,7 +44,7 @@ export class DS4Check extends DiceTerm { ? parseInt(parseMinimumFumbleResult) : DS4Check.DEFAULT_MINIMUM_FUMBLE_RESULT; if (this.minimumFumbleResult <= this.maximumCoupResult) - throw new SyntaxError(game.i18n.localize("DS4.ErrorDiceCritOverlap")); + throw new SyntaxError(game.i18n.localize("DS4.ErrorDiceCoupFumbleOverlap")); } // Parse and store no fumble diff --git a/src/module/rolls/slaying-dice-modifier.ts b/src/module/rolls/slaying-dice-modifier.ts index dbb4176a..e9b434c0 100644 --- a/src/module/rolls/slaying-dice-modifier.ts +++ b/src/module/rolls/slaying-dice-modifier.ts @@ -24,6 +24,6 @@ function slay(this: DicePool, modifier: string): void { this.rolls.push(additionalRoll); this.results.push({ result: additionalRoll.total ?? 0, active: true }); } - if (checked > 1000) throw new Error(game.i18n.localize("DS4.ErrorExplodingRecursionLimitExceeded")); + if (checked > 1000) throw new Error(game.i18n.localize("DS4.ErrorSlayingDiceRecursionLimitExceeded")); } } From 2ccaa5da10d3d9cf490b7171441da58dd2508ffb Mon Sep 17 00:00:00 2001 From: Johannes Loher Date: Sat, 13 Mar 2021 22:34:59 +0100 Subject: [PATCH 14/18] Add link to PR in TODO comment --- src/module/rolls/slaying-dice-modifier.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/module/rolls/slaying-dice-modifier.ts b/src/module/rolls/slaying-dice-modifier.ts index e9b434c0..c92242ac 100644 --- a/src/module/rolls/slaying-dice-modifier.ts +++ b/src/module/rolls/slaying-dice-modifier.ts @@ -1,7 +1,7 @@ import { DS4Check } from "./check"; export default function registerSlayingDiceModifier(): void { - // TODO(types): Adjust types to allow extension of DiceTerm.MODIFIERS + // TODO(types): Adjust types to allow extension of DiceTerm.MODIFIERS (see https://github.com/League-of-Foundry-Developers/foundry-vtt-types/pull/573) // eslint-disable-next-line // @ts-ignore DicePool.MODIFIERS.x = slay; From eb0866cfa7638ab2f053e6583dc108282a300ca7 Mon Sep 17 00:00:00 2001 From: Johannes Loher Date: Sun, 14 Mar 2021 08:47:03 +0100 Subject: [PATCH 15/18] Indicate fumbles / coups on the dice-total --- src/ds4.scss | 1 + src/module/ds4.ts | 3 ++ src/module/rolls/check-factory.ts | 4 +-- src/module/rolls/roll.ts | 44 ++++++++++++++++++++++++++++ src/scss/components/_dice_total.scss | 18 ++++++++++++ src/scss/utils/_colors.scss | 2 ++ src/templates/roll/roll.hbs | 11 +++++++ 7 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 src/module/rolls/roll.ts create mode 100644 src/scss/components/_dice_total.scss create mode 100644 src/templates/roll/roll.hbs diff --git a/src/ds4.scss b/src/ds4.scss index 5d5e3eb2..657ecdba 100644 --- a/src/ds4.scss +++ b/src/ds4.scss @@ -7,6 +7,7 @@ @include meta.load-css("scss/global/grid"); @include meta.load-css("scss/global/window"); @include meta.load-css("scss/components/actor_sheet"); +@include meta.load-css("scss/components/dice_total"); /* Styles limited to ds4 sheets */ .ds4 { diff --git a/src/module/ds4.ts b/src/module/ds4.ts index f64e05c0..1219a85d 100644 --- a/src/module/ds4.ts +++ b/src/module/ds4.ts @@ -9,6 +9,7 @@ import { DS4ItemSheet } from "./item/item-sheet"; import { migration } from "./migrations"; import { DS4Check } from "./rolls/check"; import { createCheckRoll } from "./rolls/check-factory"; +import { DS4Roll } from "./rolls/roll"; import registerSlayingDiceModifier from "./rolls/slaying-dice-modifier"; import { registerSystemSettings } from "./settings"; @@ -34,6 +35,8 @@ Hooks.once("init", async () => { CONFIG.Dice.types.push(DS4Check); CONFIG.Dice.terms.s = DS4Check; + CONFIG.Dice.rolls.unshift(DS4Roll); + registerSlayingDiceModifier(); registerSystemSettings(); diff --git a/src/module/rolls/check-factory.ts b/src/module/rolls/check-factory.ts index 7e4997b1..bdf9a867 100644 --- a/src/module/rolls/check-factory.ts +++ b/src/module/rolls/check-factory.ts @@ -34,11 +34,9 @@ class CheckFactory { private checkOptions: DS4CheckFactoryOptions; async execute(): Promise { - const rollCls = CONFIG.Dice.rolls[0]; - const innerFormula = ["ds", this.createTargetValueTerm(), this.createCritTerm()].filterJoin(""); const formula = this.checkOptions.useSlayingDice ? `{${innerFormula}}x` : innerFormula; - const roll = new rollCls(formula); + const roll = Roll.create(formula); const rollModeTemplate = this.checkOptions.rollMode; return roll.toMessage({}, { rollMode: rollModeTemplate, create: true }); diff --git a/src/module/rolls/roll.ts b/src/module/rolls/roll.ts new file mode 100644 index 00000000..27d4f015 --- /dev/null +++ b/src/module/rolls/roll.ts @@ -0,0 +1,44 @@ +import { DS4Check } from "./check"; + +export class DS4Roll = Record> extends Roll { + static CHAT_TEMPLATE = "systems/ds4/templates/roll/roll.hbs"; + + /** + * This only differs from {@link Roll.render} in that it provides `isCoup` and `isFumble` properties to the roll + * template if the first dice term is a ds4 check. + * @override + */ + async render(chatOptions: Roll.ChatOptions = {}): Promise { + chatOptions = mergeObject( + { + user: game.user?._id, + flavor: null, + template: DS4Roll.CHAT_TEMPLATE, + blind: false, + }, + chatOptions, + ); + const isPrivate = chatOptions.isPrivate; + + // Execute the roll, if needed + if (!this._rolled) this.roll(); + + // Define chat data + const firstDiceTerm = this.dice[0]; + const isCoup = firstDiceTerm instanceof DS4Check && firstDiceTerm.coup; + const isFumble = firstDiceTerm instanceof DS4Check && firstDiceTerm.fumble; + + const chatData = { + formula: isPrivate ? "???" : this._formula, + flavor: isPrivate ? null : chatOptions.flavor, + user: chatOptions.user, + tooltip: isPrivate ? "" : await this.getTooltip(), + total: isPrivate ? "?" : Math.round((this.total ?? 0) * 100) / 100, + isCoup: isPrivate ? null : isCoup, + isFumble: isPrivate ? null : isFumble, + }; + + // Render the roll display template + return (renderTemplate(chatOptions.template ?? "", chatData) as unknown) as HTMLElement; // TODO(types): Make this cast unnecessary by fixing upstream + } +} diff --git a/src/scss/components/_dice_total.scss b/src/scss/components/_dice_total.scss new file mode 100644 index 00000000..1ade964e --- /dev/null +++ b/src/scss/components/_dice_total.scss @@ -0,0 +1,18 @@ +@use "../utils/colors"; + +.ds4-dice-total { + @mixin color-filter($rotation) { + filter: sepia(0.5) hue-rotate($rotation); + backdrop-filter: sepia(0.5) hue-rotate($rotation); + } + + &--coup { + color: colors.$c-coup; + @include color-filter(60deg); + } + + &--fumble { + color: colors.$c-fumble; + @include color-filter(-60deg); + } +} diff --git a/src/scss/utils/_colors.scss b/src/scss/utils/_colors.scss index 55fb8c0a..91844386 100644 --- a/src/scss/utils/_colors.scss +++ b/src/scss/utils/_colors.scss @@ -3,3 +3,5 @@ $c-black: #000; $c-light-grey: #777; $c-border-groove: #eeede0; $c-invalid-input: rgba(lightcoral, 50%); +$c-coup: #18520b; +$c-fumble: #aa0200; diff --git a/src/templates/roll/roll.hbs b/src/templates/roll/roll.hbs new file mode 100644 index 00000000..89ffe7a7 --- /dev/null +++ b/src/templates/roll/roll.hbs @@ -0,0 +1,11 @@ +
+ {{#if flavor}} +
{{flavor}}
+ {{/if}} +
+
{{formula}}
+ {{{tooltip}}} +

{{total}} +

+
+
From 919091a211013b6100d8d1517ebf540d11c4afe3 Mon Sep 17 00:00:00 2001 From: Johannes Loher Date: Sun, 14 Mar 2021 14:52:50 +0100 Subject: [PATCH 16/18] Small cleanup --- src/module/rolls/check.ts | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/src/module/rolls/check.ts b/src/module/rolls/check.ts index 58e3894c..f1ab678a 100644 --- a/src/module/rolls/check.ts +++ b/src/module/rolls/check.ts @@ -61,33 +61,25 @@ export class DS4Check extends DiceTerm { minimumFumbleResult = DS4Check.DEFAULT_MINIMUM_FUMBLE_RESULT; maximumCoupResult = DS4Check.DEFAULT_MAXIMUM_COUP_RESULT; - /** - * @override - */ + /** @override */ get expression(): string { return `ds${this.modifiers.join("")}`; } - /** - * @override - */ + /** @override */ get total(): number | null { if (this.fumble) return 0; return super.total; } - /** - * @override - */ + /** @override */ evaluate({ minimize = false, maximize = false } = {}): this { super.evaluate({ minimize, maximize }); this.evaluateResults(); return this; } - /** - * @override - */ + /** @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 }); @@ -105,10 +97,12 @@ export class DS4Check extends DiceTerm { this.fumble = results[0].failure ?? false; } - /** - * @override - */ - static fromResults(options: Partial, results: DiceTerm.Result[]): DS4Check { + /** @override */ + static fromResults( + this: ConstructorOf, + options: Partial, + results: DiceTerm.Result[], + ): T { const term = new this(options); term.results = results; term.evaluateResults(); From f3b6ed4b7f6567816642f753726a6bdcc1ba452b Mon Sep 17 00:00:00 2001 From: Johannes Loher Date: Mon, 15 Mar 2021 20:15:00 +0100 Subject: [PATCH 17/18] Make fumbles / coups more distinctive in the dice-total --- src/scss/components/_dice_total.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scss/components/_dice_total.scss b/src/scss/components/_dice_total.scss index 1ade964e..c19a0572 100644 --- a/src/scss/components/_dice_total.scss +++ b/src/scss/components/_dice_total.scss @@ -3,7 +3,7 @@ .ds4-dice-total { @mixin color-filter($rotation) { filter: sepia(0.5) hue-rotate($rotation); - backdrop-filter: sepia(0.5) hue-rotate($rotation); + backdrop-filter: sepia(0) hue-rotate($rotation); } &--coup { From 2e5e02c89fe147294e38d1828be7dac33dcbb3ce Mon Sep 17 00:00:00 2001 From: Johannes Loher Date: Mon, 15 Mar 2021 21:41:51 +0100 Subject: [PATCH 18/18] properly cast return type of Roll.render --- src/module/rolls/roll.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/module/rolls/roll.ts b/src/module/rolls/roll.ts index 27d4f015..22ec0cd5 100644 --- a/src/module/rolls/roll.ts +++ b/src/module/rolls/roll.ts @@ -39,6 +39,6 @@ export class DS4Roll = Record }; // Render the roll display template - return (renderTemplate(chatOptions.template ?? "", chatData) as unknown) as HTMLElement; // TODO(types): Make this cast unnecessary by fixing upstream + return (renderTemplate(chatOptions.template ?? "", chatData) as unknown) as Promise; // TODO(types): Make this cast unnecessary by fixing upstream } }