refactor: resturcture files so that lincensing info can be bundled properly

This commit is contained in:
Johannes Loher 2022-01-31 15:13:32 +01:00
parent 699ba74840
commit 1aa284311f
484 changed files with 119 additions and 179 deletions

View file

@ -0,0 +1,131 @@
// SPDX-FileCopyrightText: 2021 Johannes Loher
// SPDX-FileCopyrightText: 2021 Oliver Rümpelein
//
// SPDX-License-Identifier: MIT
import { getGame } from "../helpers";
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(getGame().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): [number, number] => [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: readonly number[],
checkTargetNumberForLastSubCheck: number,
) {
if (indexOfFirstCoup !== -1 && indexOfSmallestNonCoup === -1) {
return true;
}
const smallestNonCoup = dice[indexOfSmallestNonCoup];
if (
!Number.isInteger(indexOfFirstCoup) ||
indexOfFirstCoup < -1 ||
!Number.isInteger(indexOfSmallestNonCoup) ||
smallestNonCoup === undefined
) {
throw new Error("Received an invalid value for the parameter indexOfSmallestNonCoup or indexOfFirstCoup,");
}
return (
indexOfFirstCoup !== -1 &&
smallestNonCoup > checkTargetNumberForLastSubCheck &&
smallestNonCoup + checkTargetNumberForLastSubCheck > 20
);
}
interface SubCheckResult extends DieWithSubCheck, DiceTerm.Result {}
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.max(Math.ceil(checkTargetNumber / 20), 1);
}

236
src/rolls/check-factory.ts Normal file
View file

@ -0,0 +1,236 @@
// SPDX-FileCopyrightText: 2021 Johannes Loher
// SPDX-FileCopyrightText: 2021 Oliver Rümpelein
//
// SPDX-License-Identifier: MIT
import { getGame } from "../helpers";
/**
* Provides default values for all arguments the `CheckFactory` expects.
*/
class DefaultCheckOptions implements DS4CheckFactoryOptions {
readonly maximumCoupResult = 1;
readonly minimumFumbleResult = 20;
readonly useSlayingDice = false;
// TODO: Use the type `keyof CONFIG.Dice.rollModes` instead as soon as https://github.com/League-of-Foundry-Developers/foundry-vtt-types/issues/1501 has been resolved.
readonly rollMode: foundry.CONST.DICE_ROLL_MODES = "publicroll";
readonly flavor: undefined;
mergeWith(other: Partial<DS4CheckFactoryOptions>): DS4CheckFactoryOptions {
return { ...this, ...other };
}
}
/**
* Singleton reference for default value extraction.
*/
const defaultCheckOptions = new DefaultCheckOptions();
/**
* Most basic class responsible for generating the chat formula and passing it to the chat as roll.
*/
class CheckFactory {
constructor(
private checkTargetNumber: number,
private gmModifier: number,
options: Partial<DS4CheckFactoryOptions> = {},
) {
this.options = defaultCheckOptions.mergeWith(options);
}
private options: DS4CheckFactoryOptions;
async execute(): Promise<ChatMessage | undefined> {
const innerFormula = ["ds", this.createCheckTargetNumberModifier(), this.createCoupFumbleModifier()].filterJoin(
"",
);
const formula = this.options.useSlayingDice ? `{${innerFormula}}x` : innerFormula;
const roll = Roll.create(formula);
const speaker = this.options.speaker ?? ChatMessage.getSpeaker();
return roll.toMessage(
{
speaker,
flavor: this.options.flavor,
flags: this.options.flavorData ? { ds4: { flavorData: this.options.flavorData } } : undefined,
},
{ rollMode: this.options.rollMode, create: true },
);
}
createCheckTargetNumberModifier(): string {
const totalCheckTargetNumber = Math.max(this.checkTargetNumber + this.gmModifier, 0);
return `v${totalCheckTargetNumber}`;
}
createCoupFumbleModifier(): string | null {
const isMinimumFumbleResultRequired =
this.options.minimumFumbleResult !== defaultCheckOptions.minimumFumbleResult;
const isMaximumCoupResultRequired = this.options.maximumCoupResult !== defaultCheckOptions.maximumCoupResult;
if (isMinimumFumbleResultRequired || isMaximumCoupResultRequired) {
return `c${this.options.maximumCoupResult ?? ""}:${this.options.minimumFumbleResult ?? ""}`;
} else {
return null;
}
}
}
/**
* Asks the user for all unknown/necessary information and passes them on to perform a roll.
* @param checkTargetNumber - The Check Target Number ("CTN")
* @param options - Options changing the behavior of the roll and message.
*/
export async function createCheckRoll(
checkTargetNumber: number,
options: Partial<DS4CheckFactoryOptions> = {},
): Promise<ChatMessage | unknown> {
// Ask for additional required data;
const gmModifierData = await askGmModifier(checkTargetNumber, options);
const newTargetValue = gmModifierData.checkTargetNumber ?? checkTargetNumber;
const gmModifier = gmModifierData.gmModifier ?? 0;
const newOptions: Partial<DS4CheckFactoryOptions> = {
maximumCoupResult: gmModifierData.maximumCoupResult ?? options.maximumCoupResult,
minimumFumbleResult: gmModifierData.minimumFumbleResult ?? options.minimumFumbleResult,
useSlayingDice: getGame().settings.get("ds4", "useSlayingDiceForAutomatedChecks"),
rollMode: gmModifierData.rollMode ?? options.rollMode,
flavor: options.flavor,
flavorData: options.flavorData,
speaker: options.speaker,
};
// Create Factory
const cf = new CheckFactory(newTargetValue, gmModifier, newOptions);
// Possibly additional processing
// Execute roll
return cf.execute();
}
/**
* Responsible for rendering the modal interface asking for the modifier specified by GM and (currently) additional data.
*
* @notes
* At the moment, this asks for more data than it will do after some iterations.
*
* @returns The data given by the user.
*/
async function askGmModifier(
checkTargetNumber: number,
options: Partial<DS4CheckFactoryOptions> = {},
{ template, title }: { template?: string; title?: string } = {},
): Promise<Partial<IntermediateGmModifierData>> {
const usedTemplate = template ?? "systems/ds4/templates/dialogs/roll-options.hbs";
const usedTitle = title ?? getGame().i18n.localize("DS4.DialogRollOptionsDefaultTitle");
const templateData = {
title: usedTitle,
checkTargetNumber: checkTargetNumber,
maximumCoupResult: options.maximumCoupResult ?? defaultCheckOptions.maximumCoupResult,
minimumFumbleResult: options.minimumFumbleResult ?? defaultCheckOptions.minimumFumbleResult,
rollMode: options.rollMode ?? getGame().settings.get("core", "rollMode"),
rollModes: CONFIG.Dice.rollModes,
};
const renderedHtml = await renderTemplate(usedTemplate, templateData);
const dialogPromise = new Promise<HTMLFormElement>((resolve) => {
new Dialog({
title: usedTitle,
content: renderedHtml,
buttons: {
ok: {
icon: '<i class="fas fa-check"></i>',
label: getGame().i18n.localize("DS4.GenericOkButton"),
callback: (html) => {
if (!("jquery" in html)) {
throw new Error(
getGame().i18n.format("DS4.ErrorUnexpectedHtmlType", {
exType: "JQuery",
realType: "HTMLElement",
}),
);
} else {
const innerForm = html[0]?.querySelector("form");
if (!innerForm) {
throw new Error(
getGame().i18n.format("DS4.ErrorCouldNotFindHtmlElement", { htmlElement: "form" }),
);
}
resolve(innerForm);
}
},
},
cancel: {
icon: '<i class="fas fa-times"></i>',
label: getGame().i18n.localize("DS4.GenericCancelButton"),
},
},
default: "ok",
}).render(true);
});
const dialogForm = await dialogPromise;
return parseDialogFormData(dialogForm);
}
/**
* Extracts Dialog data from the returned DOM element.
* @param formData - The filed dialog
*/
function parseDialogFormData(formData: HTMLFormElement): Partial<IntermediateGmModifierData> {
const chosenCheckTargetNumber = parseInt(formData["check-target-number"]?.value);
const chosenGMModifier = parseInt(formData["gm-modifier"]?.value);
const chosenMaximumCoupResult = parseInt(formData["maximum-coup-result"]?.value);
const chosenMinimumFumbleResult = parseInt(formData["minimum-fumble-result"]?.value);
const chosenRollMode = formData["roll-mode"]?.value;
return {
checkTargetNumber: Number.isSafeInteger(chosenCheckTargetNumber) ? chosenCheckTargetNumber : undefined,
gmModifier: Number.isSafeInteger(chosenGMModifier) ? chosenGMModifier : undefined,
maximumCoupResult: Number.isSafeInteger(chosenMaximumCoupResult) ? chosenMaximumCoupResult : undefined,
minimumFumbleResult: Number.isSafeInteger(chosenMinimumFumbleResult) ? chosenMinimumFumbleResult : undefined,
rollMode: Object.keys(CONFIG.Dice.rollModes).includes(chosenRollMode) ? chosenRollMode : undefined,
};
}
/**
* Contains data that needs retrieval from an interactive Dialog.
*/
interface GmModifierData {
gmModifier: number;
rollMode: foundry.CONST.DICE_ROLL_MODES;
}
/**
* Contains *CURRENTLY* necessary Data for drafting a roll.
*
* @deprecated
* Quite a lot of this information is requested due to a lack of automation:
* - maximumCoupResult
* - minimumFumbleResult
* - useSlayingDice
* - checkTargetNumber
*
* They will and should be removed once effects and data retrieval is in place.
* If a "raw" roll dialog is necessary, create another pre-processing Dialog
* class asking for the required information.
* This interface should then be replaced with the `GmModifierData`.
*/
interface IntermediateGmModifierData extends GmModifierData {
checkTargetNumber: number;
maximumCoupResult: number;
minimumFumbleResult: number;
}
/**
* The minimum behavioral options that need to be passed to the factory.
*/
export interface DS4CheckFactoryOptions {
maximumCoupResult: number;
minimumFumbleResult: number;
useSlayingDice: boolean;
rollMode: foundry.CONST.DICE_ROLL_MODES;
flavor?: string;
flavorData?: Record<string, string | number | null>;
speaker?: ReturnType<typeof ChatMessage.getSpeaker>;
}

130
src/rolls/check.ts Normal file
View file

@ -0,0 +1,130 @@
// SPDX-FileCopyrightText: 2021 Johannes Loher
// SPDX-FileCopyrightText: 2021 Oliver Rümpelein
//
// SPDX-License-Identifier: MIT
import { getGame } from "../helpers";
import evaluateCheck, { getRequiredNumberOfDice } from "./check-evaluation";
/**
* Implements DS4 Checks as an emulated "dice throw".
*
* @example
* - Roll a check against a Check Target Number (CTN) of 18: `/r dsv18`
* - Roll a check with multiple dice against a CTN of 34: `/r dsv34`
* - Roll a check with a racial ability that makes `2` a coup and `19` a fumble: `/r dsv19c2:19`
* - Roll a check with a racial ability that makes `5` a coup and default fumble: `/r dsv19c5`
*/
export class DS4Check extends DiceTerm {
constructor({ modifiers = [], results = [], options }: Partial<DiceTerm.TermData> = {}) {
super({
faces: 20,
results,
modifiers,
options,
});
// 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;
}
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(getGame().i18n.localize("DS4.ErrorDiceCoupFumbleOverlap"));
}
// Parse and store no fumble
const noFumbleModifier = this.modifiers.filter((m) => m[0] === "n")[0];
if (noFumbleModifier) {
this.canFumble = false;
}
if (this.results.length > 0) {
this.evaluateResults();
}
}
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 */
get expression(): string {
return `ds${this.modifiers.join("")}`;
}
/** @override */
get total(): string | number | null | undefined {
if (this.fumble) return 0;
return super.total;
}
/** @override */
_evaluateSync({ minimize = false, maximize = false } = {}): this {
super._evaluateSync({ minimize, maximize });
this.evaluateResults();
return this;
}
/** @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
* @remarks "min" and "max" are filtered out because they are irrelevant for
* {@link DS4Check}s and only result in some dice rolls being highlighted
* incorrectly.
*/
getResultCSS(result: DiceTerm.Result): (string | null)[] {
return super.getResultCSS(result).filter((cssClass) => cssClass !== "min" && cssClass !== "max");
}
static readonly DEFAULT_CHECK_TARGET_NUMBER = 10;
static readonly DEFAULT_MAXIMUM_COUP_RESULT = 1;
static readonly DEFAULT_MINIMUM_FUMBLE_RESULT = 20;
static DENOMINATION = "s";
static MODIFIERS = {
c: (): void => undefined, // Modifier is consumed in constructor for maximumCoupResult / minimumFumbleResult
v: (): void => undefined, // Modifier is consumed in constructor for checkTargetNumber
n: (): void => undefined, // Modifier is consumed in constructor for canFumble
};
}

37
src/rolls/roll.ts Normal file
View file

@ -0,0 +1,37 @@
// SPDX-FileCopyrightText: 2021 Johannes Loher
//
// SPDX-License-Identifier: MIT
import { getGame } from "../helpers";
import { DS4Check } from "./check";
export class DS4Roll<D extends Record<string, unknown> = Record<string, unknown>> extends Roll<D> {
static CHAT_TEMPLATE = "systems/ds4/templates/dice/roll.hbs";
/**
* @override
* @remarks
* 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.
*/
async render({
flavor,
template = (this.constructor as typeof DS4Roll).CHAT_TEMPLATE,
isPrivate = false,
}: Parameters<Roll["render"]>[0] = {}): Promise<string> {
if (!this._evaluated) await this.evaluate({ async: true });
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 : flavor,
user: getGame().user?.id,
tooltip: isPrivate ? "" : await this.getTooltip(),
total: isPrivate ? "?" : Math.round((this.total ?? 0) * 100) / 100,
isCoup: isPrivate ? null : isCoup,
isFumble: isPrivate ? null : isFumble,
};
return renderTemplate(template, chatData);
}
}

View file

@ -0,0 +1,32 @@
// SPDX-FileCopyrightText: 2021 Johannes Loher
// SPDX-FileCopyrightText: 2021 Oliver Rümpelein
//
// SPDX-License-Identifier: MIT
import { getGame } from "../helpers";
import { DS4Check } from "./check";
export default function registerSlayingDiceModifier(): void {
PoolTerm.MODIFIERS.x = slay;
}
function slay(this: PoolTerm, 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({ async: false });
this.rolls.push(additionalRoll);
this.results.push({ result: additionalRoll.total ?? 0, active: true });
this.terms.push(formula);
}
if (checked > 1000) throw new Error(getGame().i18n.localize("DS4.ErrorSlayingDiceRecursionLimitExceeded"));
}
}