Make dice viewable seperately in DS4Check

This commit is contained in:
Johannes Loher 2021-03-13 17:43:48 +01:00
parent a542dd1575
commit 9c1d2f081a
8 changed files with 157 additions and 651 deletions

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

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

View file

@ -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<string>;
options: Record<string, unknown>;
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<TermData>) {
constructor({ modifiers = [], options = {}, canFumble = true }: Partial<DS4CheckTermData> = {}) {
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<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
*/
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<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);
// }, []);
// }
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",
};
}

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