Merge branch 'master' into item-macros
This commit is contained in:
commit
48e06678a9
20 changed files with 610 additions and 688 deletions
|
@ -1,17 +1,19 @@
|
|||
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 { macros } from "./macros/macros";
|
||||
import registerHooks from "./hooks/hooks";
|
||||
import { DS4Item } from "./item/item";
|
||||
import { DS4ItemSheet } from "./item/item-sheet";
|
||||
import { macros } from "./macros/macros";
|
||||
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}`);
|
||||
|
@ -36,6 +38,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);
|
||||
|
|
117
src/module/rolls/check-evaluation.ts
Normal file
117
src/module/rolls/check-evaluation.ts
Normal 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);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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>;
|
||||
}
|
|
@ -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
44
src/module/rolls/roll.ts
Normal 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
|
||||
}
|
||||
}
|
29
src/module/rolls/slaying-dice-modifier.ts
Normal file
29
src/module/rolls/slaying-dice-modifier.ts
Normal 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"));
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue