// SPDX-FileCopyrightText: 2021 Johannes Loher // SPDX-FileCopyrightText: 2021 Oliver Rümpelein // // SPDX-License-Identifier: MIT import { DialogWithListeners } from "../apps/dialog-with-listeners"; import { DS4 } from "../config"; import { getGame } from "../utils/utils"; /** @typedef {"publicroll" | "gmroll" | "gmroll" | "selfroll"} RollModes */ /** * Most basic class responsible for generating the chat formula and passing it to the chat as roll. */ class CheckFactory { /** * @param {number} checkTargetNumber The check target number for this check factory * @param {number} checkModifier The check modifier for this check factory * @param {Partial} [options] Options for this check factory */ constructor(checkTargetNumber, checkModifier, options = {}) { this.#checkTargetNumber = checkTargetNumber; this.#checkModifier = checkModifier; this.#options = foundry.utils.mergeObject(this.constructor.defaultOptions, options); } /** * The check target number for this check factory. * @type {number} */ #checkTargetNumber; /** * The check modifier for this check factory. * @type {number} */ #checkModifier; /** * The options for this check factory. * @type {DS4CheckFactoryOptions} */ #options; /** * The default options of thos CheckFactory class. Upon instantiation, they are merged with the explicitly provided options. * @type {DS4CheckFactoryOptions} */ static get defaultOptions() { return { maximumCoupResult: 1, minimumFumbleResult: 20, useSlayingDice: false, rollMode: "publicroll", }; } /** * Execute this check factory. * @returns {Promise} A promise that resolves to the created chat message for the roll */ async execute() { 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 }, ); } /** * Create the check target number modifier for this roll. * @returns string */ createCheckTargetNumberModifier() { const totalCheckTargetNumber = this.#checkTargetNumber + this.#checkModifier; return totalCheckTargetNumber >= 0 ? `v${this.#checkTargetNumber + this.#checkModifier}` : "v0"; } /** * Create the coup fumble modifier for this roll. * @returns {string | null} */ createCoupFumbleModifier() { const isMinimumFumbleResultRequired = this.#options.minimumFumbleResult !== this.constructor.defaultOptions.minimumFumbleResult; const isMaximumCoupResultRequired = this.#options.maximumCoupResult !== this.constructor.defaultOptions.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 {number} checkTargetNumber The Check Target Number ("CTN") * @param {Partial} [options={}] Options changing the behavior of the roll and message. * @returns {Promise} A promise that resolves to the chat message created by the roll */ export async function createCheckRoll(checkTargetNumber, options = {}) { // Ask for additional required data; const interactiveRollData = await askForInteractiveRollData(checkTargetNumber, options); const newTargetValue = interactiveRollData.checkTargetNumber ?? checkTargetNumber; const checkModifier = interactiveRollData.checkModifier ?? 0; /** @type {Partial} */ const newOptions = { maximumCoupResult: interactiveRollData.maximumCoupResult ?? options.maximumCoupResult, minimumFumbleResult: interactiveRollData.minimumFumbleResult ?? options.minimumFumbleResult, useSlayingDice: getGame().settings.get("ds4", "useSlayingDiceForAutomatedChecks"), rollMode: interactiveRollData.rollMode ?? options.rollMode, flavor: options.flavor, flavorData: options.flavorData, speaker: options.speaker, }; // Create Factory const cf = new CheckFactory(newTargetValue, checkModifier, newOptions); // Possibly additional processing // Execute roll return cf.execute(); } /** * Responsible for rendering the modal interface asking for the check modifier and (currently) additional * data. * * @notes * At the moment, this asks for more data than it will do after some iterations. * * @param {number} checkTargetNumber The check target number * @param {Partial} [options={}] Predefined roll options * @param {template?: string | undefined; title?: string | undefined} [additionalOptions={}] Additional options to use for the dialog * @returns {Promise>} A promise that resolves to the data given by the user. */ async function askForInteractiveRollData(checkTargetNumber, options = {}, { template, title } = {}) { const usedTemplate = template ?? "systems/ds4/templates/dialogs/roll-options.hbs"; const usedTitle = title ?? getGame().i18n.localize("DS4.DialogRollOptionsDefaultTitle"); const id = foundry.utils.randomID(); const templateData = { title: usedTitle, checkTargetNumber: checkTargetNumber, maximumCoupResult: options.maximumCoupResult ?? this.constructor.defaultOptions.maximumCoupResult, minimumFumbleResult: options.minimumFumbleResult ?? this.constructor.defaultOptions.minimumFumbleResult, rollMode: options.rollMode ?? getGame().settings.get("core", "rollMode"), rollModes: CONFIG.Dice.rollModes, checkModifiers: Object.entries(DS4.i18n.checkModifiers).map(([key, translation]) => { if (key in DS4.checkModifiers) { const value = DS4.checkModifiers[key]; const label = `${translation} (${value >= 0 ? `+${value}` : value})`; return { value, label }; } return { value: key, label: translation }; }), id, }; const renderedHtml = await renderTemplate(usedTemplate, templateData); const dialogPromise = new Promise((resolve) => { new DialogWithListeners( { title: usedTitle, content: renderedHtml, buttons: { ok: { icon: '', 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: '', label: getGame().i18n.localize("DS4.GenericCancelButton"), }, }, default: "ok", }, { activateAdditionalListeners: (html, app) => { const checkModifierCustomFormGroup = html .find(`#check-modifier-custom-${id}`) .parent(".form-group"); html.find(`#check-modifier-${id}`).on("change", (event) => { if ( event.currentTarget.value === "custom" && checkModifierCustomFormGroup.hasClass("ds4-hidden") ) { checkModifierCustomFormGroup.removeClass("ds4-hidden"); app.setPosition({ height: "auto" }); } else if (!checkModifierCustomFormGroup.hasClass("ds4-hidden")) { checkModifierCustomFormGroup.addClass("ds4-hidden"); app.setPosition({ height: "auto" }); } }); }, id, }, ).render(true); }); const dialogForm = await dialogPromise; return parseDialogFormData(dialogForm); } /** * Extracts Dialog data from the returned DOM element. * @param {HTMLFormElement} formData The filed dialog * @returns {Partial} */ function parseDialogFormData(formData) { const chosenCheckTargetNumber = parseInt(formData["check-target-number"]?.value); const chosenCheckModifier = formData["check-modifier"]?.value; const chosenCheckModifierValue = chosenCheckModifier === "custom" ? parseInt(formData["check-modifier-custom"]?.value) : parseInt(chosenCheckModifier); 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, checkModifier: Number.isSafeInteger(chosenCheckModifierValue) ? chosenCheckModifierValue : 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. * @typedef {object} InteractiveRollData * @property {number} checkModifier * @property {RollModes} rollMode */ /** * 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 * - 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 {@link InteractiveRollData}. * @typedef {object} IntermediateInteractiveRollData * @property {number} checkTargetNumber * @property {number} maximumCoupResult * @property {number} minimumFumbleResult */ /** * The minimum behavioral options that need to be passed to the factory. * @typedef {object} DS4CheckFactoryOptions * @property {number} maximumCoupResult * @property {number} minimumFumbleResult * @property {boolean} useSlayingDice * @property {RollModes} rollMode * @property {string} [flavor] * @property {Record} [flavorData] * @property {ChatSpeakerData} [speaker] */ /** * @typedef {object} ChatSpeakerData * @property {string | null} [scene] * @property {string | null} [actor] * @property {string | null} [token] * @property {string | null} [alias] */