Merge branch '039-make-dice-viewable-seperately' into 'master'

Make dice viewable seperately

Closes #39

See merge request dungeonslayers/ds4!90
This commit is contained in:
Johannes Loher 2021-03-15 20:43:14 +00:00
commit def905d4f9
20 changed files with 609 additions and 687 deletions

View file

@ -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"

View file

@ -0,0 +1,269 @@
import evaluateCheck from "../../src/module/rolls/check-evaluation";
Object.defineProperty(globalThis, "game", { value: { i18n: { localize: (key: string) => key } } });
describe("evaluateCheck with no dice", () => {
it("should throw an error.", () => {
expect(() => evaluateCheck([], 10)).toThrow("DS4.ErrorInvalidNumberOfDice");
});
});
describe("evaluateCheck with more dice than required by the checkTargetNumber", () => {
it("should throw an error.", () => {
expect(() => evaluateCheck([10, 10], 10)).toThrow("DS4.ErrorInvalidNumberOfDice");
});
});
describe("evaluateCheck with less dice than required by the checkTargetNumber", () => {
it("should throw an error.", () => {
expect(() => evaluateCheck([10], 21)).toThrow("DS4.ErrorInvalidNumberOfDice");
});
});
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 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 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 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 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 and fumble on '20'", () => {
expect(evaluateCheck([20], 4)).toEqual([
{ result: 20, checkTargetNumber: 4, active: false, discarded: true, failure: true },
]);
});
});
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 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("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(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(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(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(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(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(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(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(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(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(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(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("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(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(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(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(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(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(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(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(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(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(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(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 },
]);
});
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 },
]);
});
});

View file

@ -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]),
);
});
});

View file

@ -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();
});
});

View file

@ -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 {

View file

@ -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.",

View file

@ -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}'.",

View file

@ -1,15 +1,17 @@
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 { DS4Roll } from "./rolls/roll";
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 +35,10 @@ Hooks.once("init", async () => {
CONFIG.Dice.types.push(DS4Check);
CONFIG.Dice.terms.s = DS4Check;
CONFIG.Dice.rolls.unshift(DS4Roll);
registerSlayingDiceModifier();
registerSystemSettings();
Actors.unregisterSheet("core", ActorSheet);

View file

@ -0,0 +1,117 @@
export default function evaluateCheck(
dice: number[],
checkTargetNumber: number,
{
maximumCoupResult = 1,
minimumFumbleResult = 20,
canFumble = true,
}: { maximumCoupResult?: number; minimumFumbleResult?: number; canFumble?: boolean } = {},
): SubCheckResult[] {
const diceWithSubChecks = assignSubChecksToDice(dice, checkTargetNumber, {
maximumCoupResult: maximumCoupResult,
});
return evaluateDiceWithSubChecks(diceWithSubChecks, {
maximumCoupResult: maximumCoupResult,
minimumFumbleResult: minimumFumbleResult,
canFumble: canFumble,
});
}
interface DieWithSubCheck {
result: number;
checkTargetNumber: number;
}
function assignSubChecksToDice(
dice: number[],
checkTargetNumber: number,
{
maximumCoupResult = 1,
}: {
maximumCoupResult?: number;
} = {},
): DieWithSubCheck[] {
const requiredNumberOfDice = getRequiredNumberOfDice(checkTargetNumber);
if (dice.length !== requiredNumberOfDice || requiredNumberOfDice < 1) {
throw new Error(game.i18n.localize("DS4.ErrorInvalidNumberOfDice"));
}
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))
);
}
interface SubCheckResult extends DieWithSubCheck, DiceTerm.Result {
success?: boolean;
failure?: boolean;
count?: number;
}
function evaluateDiceWithSubChecks(
results: DieWithSubCheck[],
{
maximumCoupResult,
minimumFumbleResult,
canFumble,
}: { maximumCoupResult: number; minimumFumbleResult: number; canFumble: boolean },
): SubCheckResult[] {
return results.map((dieWithSubCheck, index) => {
const result: SubCheckResult = {
...dieWithSubCheck,
active: dieWithSubCheck.result <= dieWithSubCheck.checkTargetNumber,
discarded: dieWithSubCheck.result > dieWithSubCheck.checkTargetNumber,
};
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;
});
}
export function getRequiredNumberOfDice(checkTargetNumber: number): number {
return Math.ceil(checkTargetNumber / 20);
}

View file

@ -34,15 +34,9 @@ class CheckFactory {
private checkOptions: DS4CheckFactoryOptions;
async execute(): Promise<ChatMessage | unknown> {
const rollCls = CONFIG.Dice.rolls[0];
const formula = [
"ds",
this.createTargetValueTerm(),
this.createCritTerm(),
this.createSlayingDiceTerm(),
].filterJoin("");
const roll = new rollCls(formula);
const innerFormula = ["ds", this.createTargetValueTerm(), this.createCritTerm()].filterJoin("");
const formula = this.checkOptions.useSlayingDice ? `{${innerFormula}}x` : innerFormula;
const roll = Roll.create(formula);
const rollModeTemplate = this.checkOptions.rollMode;
return roll.toMessage({}, { rollMode: rollModeTemplate, create: true });
@ -62,15 +56,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;
}
}
/**

View file

@ -1,136 +1,122 @@
import { RollResult, RollResultStatus } from "./roll-data";
import { ds4roll } from "./roll-executor";
interface TermData {
number: number;
faces: number;
modifiers: Array<string>;
options: Record<string, unknown>;
}
import evaluateCheck, { getRequiredNumberOfDice } from "./check-evaluation";
/**
* 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 `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<TermData>) {
constructor({ modifiers = [], options }: Partial<DiceTerm.TermData> = {}) {
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.
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;
// Parse and store check target number
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;
}
// Store and parse min/max crit
const critModifier = this.modifiers.filter((m) => m[0] === "c")[0];
const cmRgx = new RegExp("c([0-9]+)?,([0-9]+)?");
const cmMatch = critModifier?.match(cmRgx);
if (cmMatch) {
const [parseMaxCritSuccess, parseMinCritFailure] = cmMatch.slice(1);
this.maxCritSuccess = parseMaxCritSuccess
? parseInt(parseMaxCritSuccess)
: DS4Check.DEFAULT_MAX_CRIT_SUCCESS;
this.minCritFailure = parseMinCritFailure
? parseInt(parseMinCritFailure)
: DS4Check.DEFAULT_MIN_CRIT_FAILURE;
if (this.minCritFailure <= this.maxCritSuccess)
throw new SyntaxError(game.i18n.localize("DS4.ErrorDiceCritOverlap"));
this.number = getRequiredNumberOfDice(this.checkTargetNumber);
// 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);
if (cfmMatch) {
const parseMaximumCoupResult = cfmMatch[1];
const parseMinimumFumbleResult = cfmMatch[3];
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.ErrorDiceCoupFumbleOverlap"));
}
// Parse and store no fumble
const noFumbleModifier = this.modifiers.filter((m) => m[0] === "n")[0];
if (noFumbleModifier) {
this.canFumble = false;
}
}
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 = true;
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;
/** @override */
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<RollResult>)
.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 */
evaluate({ minimize = false, maximize = false } = {}): this {
super.evaluate({ minimize, maximize });
this.evaluateResults();
return this;
}
static readonly DEFAULT_TARGET_VALUE = 10;
static readonly DEFAULT_MAX_CRIT_SUCCESS = 1;
static readonly DEFAULT_MIN_CRIT_FAILURE = 20;
/** @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 });
}
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;
}
/** @override */
static fromResults<T extends DS4Check>(
this: ConstructorOf<T>,
options: Partial<DiceTerm.TermData>,
results: DiceTerm.Result[],
): T {
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 = {
x: "explode",
c: (): void => undefined, // Modifier is consumed in constructor for crit
v: (): void => undefined, // Modifier is consumed in constructor for target value
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
};
}

View file

@ -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>): RollOptions {
return { ...this, ...other };
}
}
export class RollResult {
constructor(
public result: number,
public status: RollResultStatus,
public dice: Array<number>,
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",
}

View file

@ -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<RollOptions> = {},
dice: Array<number> = [],
): 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<RollOptions>,
dice: Array<number> = [],
): 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<RollOptions>,
dice: Array<number> = [],
): 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<number>;
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);
}
}

View file

@ -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<number> {
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<number>;
}

View file

@ -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<number>, 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<number>, Array<number>];
/**
* 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<number>,
remainderTargetValue: number,
rollOptions: RollOptions,
): number {
const numberOfDice = assignedRollResults.length;
const maxResultPerDie: Array<number> = 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);
}

44
src/module/rolls/roll.ts Normal file
View file

@ -0,0 +1,44 @@
import { DS4Check } from "./check";
export class DS4Roll<D extends Record<string, unknown> = Record<string, unknown>> extends Roll<D> {
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<HTMLElement> {
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 Promise<HTMLElement>; // TODO(types): Make this cast unnecessary by fixing upstream
}
}

View file

@ -0,0 +1,29 @@
import { DS4Check } from "./check";
export default function registerSlayingDiceModifier(): void {
// 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;
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.ErrorSlayingDiceRecursionLimitExceeded"));
}
}

View file

@ -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) hue-rotate($rotation);
}
&--coup {
color: colors.$c-coup;
@include color-filter(60deg);
}
&--fumble {
color: colors.$c-fumble;
@include color-filter(-60deg);
}
}

View file

@ -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;

View file

@ -0,0 +1,11 @@
<div class="dice-roll">
{{#if flavor}}
<div class="dice-flavor">{{flavor}}</div>
{{/if}}
<div class="dice-result">
<div class="dice-formula">{{formula}}</div>
{{{tooltip}}}
<h4 class="dice-total{{#if isFumble}} ds4-dice-total--fumble{{/if}}{{#if isCoup}} ds4-dice-total--coup{{/if}}">{{total}}
</h4>
</div>
</div>