322 lines
11 KiB
JavaScript
322 lines
11 KiB
JavaScript
import HandlebarsApplicationMixin from "../api/handlebars-application.mjs";
|
|
import ApplicationV2 from "../api/application.mjs";
|
|
|
|
/**
|
|
* @typedef {object} DiceTermFulfillmentDescriptor
|
|
* @property {string} id A unique identifier for the term.
|
|
* @property {DiceTerm} term The term.
|
|
* @property {string} method The fulfillment method.
|
|
* @property {boolean} [isNew] Was the term newly-added to this resolver?
|
|
*/
|
|
|
|
/**
|
|
* An application responsible for handling unfulfilled dice terms in a roll.
|
|
* @extends {ApplicationV2<ApplicationConfiguration, ApplicationRenderOptions>}
|
|
* @mixes HandlebarsApplication
|
|
* @alias RollResolver
|
|
*/
|
|
export default class RollResolver extends HandlebarsApplicationMixin(ApplicationV2) {
|
|
constructor(roll, options={}) {
|
|
super(options);
|
|
this.#roll = roll;
|
|
}
|
|
|
|
/** @inheritDoc */
|
|
static DEFAULT_OPTIONS = {
|
|
id: "roll-resolver-{id}",
|
|
tag: "form",
|
|
classes: ["roll-resolver"],
|
|
window: {
|
|
title: "DICE.RollResolution",
|
|
},
|
|
position: {
|
|
width: 500,
|
|
height: "auto"
|
|
},
|
|
form: {
|
|
submitOnChange: false,
|
|
closeOnSubmit: false,
|
|
handler: this._fulfillRoll
|
|
}
|
|
};
|
|
|
|
/** @override */
|
|
static PARTS = {
|
|
form: {
|
|
id: "form",
|
|
template: "templates/dice/roll-resolver.hbs"
|
|
}
|
|
};
|
|
|
|
/**
|
|
* A collection of fulfillable dice terms.
|
|
* @type {Map<string, DiceTermFulfillmentDescriptor>}
|
|
*/
|
|
get fulfillable() {
|
|
return this.#fulfillable;
|
|
}
|
|
|
|
#fulfillable = new Map();
|
|
|
|
/**
|
|
* A function to call when the first pass of fulfillment is complete.
|
|
* @type {function}
|
|
*/
|
|
#resolve;
|
|
|
|
/**
|
|
* The roll being resolved.
|
|
* @type {Roll}
|
|
*/
|
|
get roll() {
|
|
return this.#roll;
|
|
}
|
|
|
|
#roll;
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Identify any terms in this Roll that should be fulfilled externally, and prompt the user to do so.
|
|
* @returns {Promise<void>} Returns a Promise that resolves when the first pass of fulfillment is complete.
|
|
*/
|
|
async awaitFulfillment() {
|
|
const fulfillable = await this.#identifyFulfillableTerms(this.roll.terms);
|
|
if ( !fulfillable.length ) return;
|
|
Roll.defaultImplementation.RESOLVERS.set(this.roll, this);
|
|
this.render(true);
|
|
return new Promise(resolve => this.#resolve = resolve);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Register a fulfilled die roll.
|
|
* @param {string} method The method used for fulfillment.
|
|
* @param {string} denomination The denomination of the fulfilled die.
|
|
* @param {number} result The rolled number.
|
|
* @returns {boolean} Whether the result was consumed.
|
|
*/
|
|
registerResult(method, denomination, result) {
|
|
const query = `label[data-denomination="${denomination}"][data-method="${method}"] > input:not(:disabled)`;
|
|
const term = Array.from(this.element.querySelectorAll(query)).find(input => input.value === "");
|
|
if ( !term ) {
|
|
ui.notifications.warn(`${denomination} roll was not needed by the resolver.`);
|
|
return false;
|
|
}
|
|
term.value = `${result}`;
|
|
const submitTerm = term.closest(".form-fields")?.querySelector("button");
|
|
if ( submitTerm ) submitTerm.dispatchEvent(new MouseEvent("click"));
|
|
else this._checkDone();
|
|
return true;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritDoc */
|
|
async close(options={}) {
|
|
if ( this.rendered ) await this.constructor._fulfillRoll.call(this, null, null, new FormDataExtended(this.element));
|
|
Roll.defaultImplementation.RESOLVERS.delete(this.roll);
|
|
this.#resolve?.();
|
|
return super.close(options);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritDoc */
|
|
async _prepareContext(_options) {
|
|
const context = {
|
|
formula: this.roll.formula,
|
|
groups: {}
|
|
};
|
|
for ( const fulfillable of this.fulfillable.values() ) {
|
|
const { id, term, method, isNew } = fulfillable;
|
|
fulfillable.isNew = false;
|
|
const config = CONFIG.Dice.fulfillment.methods[method];
|
|
const group = context.groups[id] = {
|
|
results: [],
|
|
label: term.expression,
|
|
icon: config.icon ?? '<i class="fas fa-bluetooth"></i>',
|
|
tooltip: game.i18n.localize(config.label)
|
|
};
|
|
const { denomination, faces } = term;
|
|
const icon = CONFIG.Dice.fulfillment.dice[denomination]?.icon;
|
|
for ( let i = 0; i < Math.max(term.number ?? 1, term.results.length); i++ ) {
|
|
const result = term.results[i];
|
|
const { result: value, exploded, rerolled } = result ?? {};
|
|
group.results.push({
|
|
denomination, faces, id, method, icon, exploded, rerolled, isNew,
|
|
value: value ?? "",
|
|
readonly: method !== "manual",
|
|
disabled: !!result
|
|
});
|
|
}
|
|
}
|
|
return context;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritDoc */
|
|
async _onSubmitForm(formConfig, event) {
|
|
this._toggleSubmission(false);
|
|
this.element.querySelectorAll("input").forEach(input => {
|
|
if ( !isNaN(input.valueAsNumber) ) return;
|
|
const { term } = this.fulfillable.get(input.name);
|
|
input.value = `${term.randomFace()}`;
|
|
});
|
|
await super._onSubmitForm(formConfig, event);
|
|
this.element?.querySelectorAll("input").forEach(input => input.disabled = true);
|
|
this.#resolve();
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Handle prompting for a single extra result from a term.
|
|
* @param {DiceTerm} term The term.
|
|
* @param {string} method The method used to obtain the result.
|
|
* @param {object} [options]
|
|
* @returns {Promise<number|void>}
|
|
*/
|
|
async resolveResult(term, method, { reroll=false, explode=false }={}) {
|
|
const group = this.element.querySelector(`fieldset[data-term-id="${term._id}"]`);
|
|
if ( !group ) {
|
|
console.warn("Attempted to resolve a single result for an unregistered DiceTerm.");
|
|
return;
|
|
}
|
|
const fields = document.createElement("div");
|
|
fields.classList.add("form-fields");
|
|
fields.innerHTML = `
|
|
<label class="icon die-input new-addition" data-denomination="${term.denomination}" data-method="${method}">
|
|
<input type="number" min="1" max="${term.faces}" step="1" name="${term._id}"
|
|
${method === "manual" ? "" : "readonly"} placeholder="${game.i18n.localize(term.denomination)}">
|
|
${reroll ? '<i class="fas fa-arrow-rotate-right"></i>' : ""}
|
|
${explode ? '<i class="fas fa-burst"></i>' : ""}
|
|
${CONFIG.Dice.fulfillment.dice[term.denomination]?.icon ?? ""}
|
|
</label>
|
|
<button type="button" class="submit-result" data-tooltip="DICE.SubmitRoll"
|
|
aria-label="${game.i18n.localize("DICE.SubmitRoll")}">
|
|
<i class="fas fa-arrow-right"></i>
|
|
</button>
|
|
`;
|
|
group.appendChild(fields);
|
|
this.setPosition({ height: "auto" });
|
|
return new Promise(resolve => {
|
|
const button = fields.querySelector("button");
|
|
const input = fields.querySelector("input");
|
|
button.addEventListener("click", () => {
|
|
if ( !input.validity.valid ) {
|
|
input.form.reportValidity();
|
|
return;
|
|
}
|
|
let value = input.valueAsNumber;
|
|
if ( !value ) value = term.randomFace();
|
|
input.value = `${value}`;
|
|
input.disabled = true;
|
|
button.remove();
|
|
resolve(value);
|
|
});
|
|
});
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Update the Roll instance with the fulfilled results.
|
|
* @this {RollResolver}
|
|
* @param {SubmitEvent} event The originating form submission event.
|
|
* @param {HTMLFormElement} form The form element that was submitted.
|
|
* @param {FormDataExtended} formData Processed data for the submitted form.
|
|
* @returns {Promise<void>}
|
|
* @protected
|
|
*/
|
|
static async _fulfillRoll(event, form, formData) {
|
|
// Update the DiceTerms with the fulfilled values.
|
|
for ( let [id, results] of Object.entries(formData.object) ) {
|
|
const { term } = this.fulfillable.get(id);
|
|
if ( !Array.isArray(results) ) results = [results];
|
|
for ( const result of results ) {
|
|
const roll = { result: undefined, active: true };
|
|
// A null value indicates the user wishes to skip external fulfillment and fall back to the digital roll.
|
|
if ( result === null ) roll.result = term.randomFace();
|
|
else roll.result = result;
|
|
term.results.push(roll);
|
|
}
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Identify any of the given terms which should be fulfilled externally.
|
|
* @param {RollTerm[]} terms The terms.
|
|
* @param {object} [options]
|
|
* @param {boolean} [options.isNew=false] Whether this term is a new addition to the already-rendered RollResolver.
|
|
* @returns {Promise<DiceTerm[]>}
|
|
*/
|
|
async #identifyFulfillableTerms(terms, { isNew=false }={}) {
|
|
const config = game.settings.get("core", "diceConfiguration");
|
|
const fulfillable = Roll.defaultImplementation.identifyFulfillableTerms(terms);
|
|
fulfillable.forEach(term => {
|
|
if ( term._id ) return;
|
|
const method = config[term.denomination] || CONFIG.Dice.fulfillment.defaultMethod;
|
|
const id = foundry.utils.randomID();
|
|
term._id = id;
|
|
term.method = method;
|
|
this.fulfillable.set(id, { id, term, method, isNew });
|
|
});
|
|
return fulfillable;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Add a new term to the resolver.
|
|
* @param {DiceTerm} term The term.
|
|
* @returns {Promise<void>} Returns a Promise that resolves when the term's results have been externally fulfilled.
|
|
*/
|
|
async addTerm(term) {
|
|
if ( !(term instanceof foundry.dice.terms.DiceTerm) ) {
|
|
throw new Error("Only DiceTerm instances may be added to the RollResolver.");
|
|
}
|
|
const fulfillable = await this.#identifyFulfillableTerms([term], { isNew: true });
|
|
if ( !fulfillable.length ) return;
|
|
this.render({ force: true, position: { height: "auto" } });
|
|
return new Promise(resolve => this.#resolve = resolve);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Check if all rolls have been fulfilled.
|
|
* @protected
|
|
*/
|
|
_checkDone() {
|
|
// If the form has already in the submission state, we don't need to re-submit.
|
|
const submitter = this.element.querySelector('button[type="submit"]');
|
|
if ( submitter.disabled ) return;
|
|
|
|
// If there are any manual inputs, or if there are any empty inputs, then fulfillment is not done.
|
|
if ( this.element.querySelector("input:not([readonly], :disabled)") ) return;
|
|
for ( const input of this.element.querySelectorAll("input[readonly]:not(:disabled)") ) {
|
|
if ( input.value === "" ) return;
|
|
}
|
|
this.element.requestSubmit(submitter);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Toggle the state of the submit button.
|
|
* @param {boolean} enabled Whether the button is enabled.
|
|
* @protected
|
|
*/
|
|
_toggleSubmission(enabled) {
|
|
const submit = this.element.querySelector('button[type="submit"]');
|
|
const icon = submit.querySelector("i");
|
|
icon.className = `fas ${enabled ? "fa-check" : "fa-spinner fa-pulse"}`;
|
|
submit.disabled = !enabled;
|
|
}
|
|
}
|