/** * L5R Dice picker dialog * @extends {FormApplication} */ export class DicePickerDialog extends FormApplication { /** * Current Actor * @type {ActorL5r5e} * @private */ _actor = null; /** * Current used Item (Technique, Weapon) * @type {ItemL5r5e} * @private */ _item = null; /** * Current Target (Token) * @type {TokenDocument} * @private */ _target = null; /** * If GM or Constructor set to hidden, lock the player choice, so he cannot look the TN * @type {{gm: boolean, option: boolean}} * @private */ _difficultyHiddenIsLock = { gm: false, option: false, }; /** * Payload Object */ object = { ring: { id: "void", value: 1, }, skill: { id: "", value: 0, defaultValue: 0, cat: "", list: [], name: "", assistance: 0, }, difficulty: { value: 2, hidden: false, addVoidPoint: false, }, useVoidPoint: false, isInitiativeRoll: false, }; /** * Assign the default options * @override */ static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { id: "l5r5e-dice-picker-dialog", classes: ["l5r5e", "dice-picker-dialog"], template: CONFIG.l5r5e.paths.templates + "dice/dice-picker-dialog.html", title: game.i18n.localize("l5r5e.dice.dicepicker.title"), actor: null, ringId: null, skillId: "", difficulty: 2, difficultyHidden: false, }); } /** * Define a unique and dynamic element ID for the rendered application */ get id() { return `l5r5e-dice-picker-dialog-${this._actor?.id ?? "no-actor"}`; } /** * Add a create macro button on top of sheet * @override */ _getHeaderButtons() { let buttons = super._getHeaderButtons(); buttons.unshift({ label: game.i18n.localize("l5r5e.dice.dicepicker.bt_add_macro"), class: "bt-add-macro", icon: "fas fa-star", onclick: async () => { await this._createMacro(); }, }); return buttons; } /** * Create dialog * * ex: new game.l5r5e.DicePickerDialog({skillId: 'aesthetics', ringId: 'water', actor: game.user.character}).render(true); * * Options : * actor {Actor} Any `Actor` object instance. Ex : `game.user.character`, `canvas.tokens.controlled[0].actor` * actorId {string} This is the `id` not the `uuid` of an actor. Ex : "AbYgKrNwWeAxa9jT" * actorName {string} Careful this is case-sensitive. Ex : "Isawa Aki" * difficulty {number} `1` to `9` * difficultyHidden {boolean} If `true`, hide the difficulty and lock the view for the player. * isInitiativeRoll {boolean} `true` if this is an initiative roll * item {Item} The object of technique or weapon used for this roll. * itemUuid {string} The `uuid` of technique or weapon used for this roll. Can be anything retrieved by `fromUuid()` or `fromUuidSync()` * ringId {string} If not provided, take the current stance of the actor if any. Ex : "fire", "water" * skillId {string} Skill `id`. Ex : "design", "aesthetics", "courtesy" * skillCatId {string} Skill category `id`. Ex : "artisan", "scholar" * skillsList {string[]} `skillId`/`skillCatId` list coma separated. Allow the player to select the skill used in a select. Ex : "artisan,design" * target {TokenDocument} The targeted Token * * @param options actor, actorId, actorName, difficulty, difficultyHidden, isInitiativeRoll, item, itemUuid, ringId, skillId, skillCatId, skillsList, target */ constructor(options = {}) { super({}, options); // Try to get Actor from: options, first selected token or player's selected character [ options?.actor, game.actors.get(options?.actorId), game.actors.getName(options?.actorName), canvas.tokens.controlled[0]?.actor, game.user.character, ].forEach((actor) => { if (!this._actor) { this.actor = actor; } }); // Ring if (options.ringId) { this.ringId = options.ringId; } // SkillList if (options.skillsList) { this.skillList = options.skillsList; } // Skill if (options.skillId) { this.skillId = options.skillId; } // SkillCategory skillCatId if (options.skillCatId) { this.skillCatId = options.skillCatId; } // Target Infos if (options.target) { this.target = options.target; } if (!this._target) { // Get the 1st selected target const targetToken = Array.from(game.user.targets).values().next()?.value?.document; if (targetToken) { this.target = targetToken; } } // Difficulty if (!options.difficulty || !this.parseDifficulty(options.difficulty)) { this.difficulty = game.settings.get(CONFIG.l5r5e.namespace, "initiative-difficulty-value"); } // DifficultyHidden if (options.difficultyHidden) { this._difficultyHiddenIsLock.option = true; } this.difficultyHidden = !!options.difficultyHidden; // InitiativeRoll this.object.isInitiativeRoll = !!options.isInitiativeRoll; // Item (weapon/technique) if (options.item) { this.item = options.item; } else if (options.itemUuid) { this.item = fromUuidSync(options.itemUuid); } } /** * Refresh data (used from socket) */ async refresh() { if (this._difficultyHiddenIsLock.option) { this.difficulty = game.settings.get(CONFIG.l5r5e.namespace, "initiative-difficulty-value"); this.difficultyHidden = false; } this.render(false); } /** * Set actor * @param {ActorL5r5e} actor */ set actor(actor) { if (!actor) { return; } if (!(actor instanceof Actor) || !actor.isOwner) { console.warn("L5R5E | DP | Actor rejected : Not a valid Actor instance or permission was denied", actor); return; } this._actor = actor; this.ringId = this._actor.system.stance; } /** * Set used item * @param {ItemL5r5e} item */ set item(item) { if (!item) { return; } if (!(item instanceof Item) || !item.isOwner) { console.warn("L5R5E | DP | Item rejected : Not a valid Item instance or permission was denied", item); return; } this._item = item; } /** * Set Target Infos object * @param {TokenDocument} targetToken */ set target(targetToken) { if (!targetToken) { return; } if (!(targetToken instanceof TokenDocument)) { console.warn("L5R5E | DP | target rejected : Not a valid TokenDocument instance", targetToken); return; } this._target = targetToken; } /** * Set ring preset * @param ringId */ set ringId(ringId) { this.object.ring.id = CONFIG.l5r5e.stances.includes(ringId) ? ringId : "void"; this.object.ring.value = this._actor.system.rings?.[this.object.ring.id] || 1; } /** * Set the list of allowed skill to choose. * Coma separated, can be a category names or skill names. * @param {string} skillsList */ set skillList(skillsList) { if (!skillsList) { return; } this.object.skill.list = this.parseSkillsList(skillsList); if (this.object.skill.list.length > 0) { // Set 1st skill if (this.useCategory) { this.skillCatId = this.object.skill.list[0].id; } else { this.skillId = this.object.skill.list[0].id; } // Remove the list if only one item if (this.object.skill.list.length === 1) { this.object.skill.list = null; } } } /** * Set and load skill's required data from actor and skillId * @param skillId */ set skillId(skillId) { if (!skillId) { return; } this.object.skill = { ...this.object.skill, id: skillId.toLowerCase().trim(), value: 0, cat: "", name: "", }; this.skillCatId = CONFIG.l5r5e.skills.get(skillId); } /** * Set and load skill's required data from actor and skillCatId * @param skillCatId */ set skillCatId(skillCatId) { if (!skillCatId) { return; } this.object.skill = { ...this.object.skill, value: 0, cat: skillCatId.toLowerCase().trim(), name: game.i18n.localize("l5r5e.skills." + skillCatId + "." + (this.object.skill.id || "title")), }; if (!this._actor) { return; } switch (this._actor.type) { case "character": this.object.skill.value = this._actor.system.skills[skillCatId]?.[this.object.skill.id] || 0; this.object.skill.defaultValue = this.object.skill.value; break; case "npc": // Skill value is in categories for npc this.object.skill.value = this._actor.system.skills[skillCatId] || 0; this.object.skill.defaultValue = this.object.skill.value; break; } } /** * Set Difficulty level (default 2) * @param difficulty */ set difficulty(difficulty) { if (this._difficultyHiddenIsLock.option) { return; } difficulty = parseInt(difficulty); if (isNaN(difficulty) || difficulty < 0) { difficulty = 2; } this.object.difficulty.value = difficulty; } /** * Set if Difficulty is Hidden or not (default) * @param isHidden */ set difficultyHidden(isHidden) { // If GM hide, then player choice don't matter this._difficultyHiddenIsLock.gm = game.settings.get(CONFIG.l5r5e.namespace, "initiative-difficulty-hidden"); if (this._difficultyHiddenIsLock.gm || this._difficultyHiddenIsLock.option) { isHidden = true; } this.object.difficulty.hidden = !!isHidden; this.object.difficulty.addVoidPoint = this.object.difficulty.hidden; this._updateVoidPointUsage(); } /** * Add the Entity name into the window title * @type {String} */ get title() { return game.i18n.localize("l5r5e.dice.dicepicker.title") + (this._actor ? " - " + this._actor.name : ""); } /** * Return true if an actor is loaded and is a NPC * @return {boolean} */ get useCategory() { return !!this._actor && this._actor.type === "npc"; } /** * Construct and return the data object used to render the HTML template for this form application. * @param options * @return {Object} */ async getData(options = null) { return { ...(await super.getData(options)), ringsList: game.l5r5e.HelpersL5r5e.getRingsList(this._actor), data: this.object, actor: this._actor, useCategory: this.useCategory, canUseVoidPoint: this.object.difficulty.addVoidPoint || !this._actor || (this._actor.isCharacterType && this._actor.system.void_points.value > 0), disableSubmit: this.object.skill.value < 1 && this.object.ring.value < 1, difficultyHiddenIsLock: this._difficultyHiddenIsLock.gm || this._difficultyHiddenIsLock.option, }; } /** * Render the dialog * @param force * @param options * @returns {Application} */ render(force, options) { options = { ...options, }; if (force === undefined) { force = true; } return super.render(force, options); } /** * Listen to html elements * @param {jQuery} html HTML content of the sheet. * @override */ activateListeners(html) { super.activateListeners(html); // Skill Selection from list html.find("select[name=skill]").on("change", async (event) => { event.preventDefault(); event.stopPropagation(); if (this.useCategory) { this.skillCatId = event.target.value; } else { this.skillId = event.target.value; } this.render(false); }); // Select Ring html.find('input[name="approach"]').on("click", async (event) => { event.preventDefault(); event.stopPropagation(); this.ringId = event.target.dataset.ringid; this.object.ring.value = parseInt(event.target.value) + (this.object.useVoidPoint ? 1 : 0); this.render(false); }); // Quantity change for difficulty, ring and skill html.find(".quantity").on("click", async (event) => { event.preventDefault(); event.stopPropagation(); const data = $(event.currentTarget); this._quantityChange(data.data("item"), data.data("value")); this.render(false); }); // Skill assistance html.find(".assistance").on("click", async (event) => { event.preventDefault(); event.stopPropagation(); const assistanceAdd = $(event.currentTarget).data("value"); if (this.object.skill.assistance > 0 || assistanceAdd > 0) { this._quantityChange("skill", assistanceAdd); } this.object.skill.assistance = Math.max( Math.min(parseInt(this.object.skill.assistance) + assistanceAdd, 9), 0 ); this.render(false); }); // Click on the Default Skill Dice html.find("#skill_default_value").on("click", async (event) => { event.preventDefault(); event.stopPropagation(); this.object.skill.value = this.object.skill.defaultValue; this.object.skill.assistance = 0; this.render(false); }); // Spend a Void point checkbox html.find("#use_void_point").on("click", async (event) => { event.preventDefault(); event.stopPropagation(); this.object.useVoidPoint = event.target.checked; this._quantityChange("ring", this.object.useVoidPoint ? 1 : -1); this.render(false); }); // Difficulty Hidden html.find("#diff_hidden").on("click", async (event) => { event.preventDefault(); event.stopPropagation(); this.object.difficulty.hidden = !this.object.difficulty.hidden; this.object.difficulty.addVoidPoint = this.object.difficulty.hidden; this._updateVoidPointUsage(); this.render(false); }); // Difficulty Add a void point html.find("#diff_add_void_point").on("click", async (event) => { event.preventDefault(); event.stopPropagation(); this.object.difficulty.addVoidPoint = !this.object.difficulty.addVoidPoint; this._updateVoidPointUsage(); this.render(false); }); } /** * This method is called upon form submission after form data is validated * @param event The initial triggering submission event * @param formData The object of validated form data with which to update the object * @returns A Promise which resolves once the update operation has completed * @override */ async _updateObject(event, formData) { if (this.object.skill.value < 1 && this.object.ring.value < 1) { return false; } // If initiative roll, check if player already have if (this.object.isInitiativeRoll) { if (!game.combat) { ui.notifications.warn("COMBAT.NoneActive", {localize: true}); return this.close(); } if (!this._actor.canDoInitiativeRoll) { ui.notifications.error( game.i18n.localize("l5r5e.conflict.initiative.already_set") + ` [${this._actor.name}]` ); return this.close(); } } // Update Actor if (this._actor) { const actorData = foundry.utils.duplicate(this._actor.system); // Update the actor stance on initiative only if (this.object.isInitiativeRoll) { actorData.stance = this.object.ring.id; } // If hidden add 1 void pt if (this.object.difficulty.addVoidPoint) { actorData.void_points.value = Math.min(actorData.void_points.value + 1, actorData.void_points.max); } // If Void point is used, minus the actor if (this.object.useVoidPoint) { actorData.void_points.value = Math.max(actorData.void_points.value - 1, 0); } // Update actor if needed const updateDiff = foundry.utils.diffObject(this._actor.system, actorData); if (Object.keys(updateDiff).length > 0) { await this._actor.update({ system: updateDiff, }); } } // Build the formula let formula = []; if (this.object.ring.value > 0) { formula.push(`${this.object.ring.value}dr`); } if (this.object.skill.value > 0) { formula.push(`${this.object.skill.value}ds`); } let message; if (this.object.isInitiativeRoll) { // Initiative roll let msgOptions = { item: this._item, skillId: this.object.skill.id, rnkMessage: null, difficulty: this.object.difficulty.value, useVoidPoint: this.object.useVoidPoint, skillAssistance: this.object.skill.assistance, difficultyHidden: this.object.difficulty.hidden, }; await this._actor.rollInitiative({ rerollInitiative: true, initiativeOptions: { formula: formula.join("+"), // updateTurn: true, messageOptions: msgOptions, }, }); // Adhesive tape to get the messageId :/ message = msgOptions.rnkMessage; delete msgOptions.rnkMessage; } else { // Regular roll, so let's roll ! const roll = await new game.l5r5e.RollL5r5e(formula.join("+")); roll.actor = this._actor; roll.l5r5e.item = this._item; roll.l5r5e.target = this._target; roll.l5r5e.stance = this.object.ring.id; roll.l5r5e.skillId = this.object.skill.id; roll.l5r5e.skillCatId = this.object.skill.cat; roll.l5r5e.difficulty = this.object.difficulty.value; roll.l5r5e.voidPointUsed = this.object.useVoidPoint; roll.l5r5e.skillAssistance = this.object.skill.assistance; roll.l5r5e.difficultyHidden = this.object.difficulty.hidden; await roll.roll(); message = await roll.toMessage(); } if (message) { // if DsN active, delay the popup for 2s new Promise((r) => setTimeout(r, !game.dice3d ? 0 : 2000)).then(() => { new game.l5r5e.RollnKeepDialog(message.id).render(true); }); } return this.close(); } /** * Change quantity between 0-9 on the element, and return the new value * @private */ _quantityChange(element, add) { this.object[element].value = Math.max(Math.min(parseInt(this.object[element].value) + add, 9), 0); } /** * Remove the use of void point if actor don't have any and use of vp is un checked * @private */ _updateVoidPointUsage() { if ( this.object.useVoidPoint && !this.object.difficulty.addVoidPoint && !!this._actor && this._actor.system.void_points.value < 1 ) { this.object.useVoidPoint = false; this._quantityChange("ring", -1); } } /** * Create a macro on the first empty space in player's bar * @private */ async _createMacro() { const params = {}; let name = "DicePicker"; if (this._actor?.id) { params.actorId = this._actor.id; name = this._actor.name; } if (this.object.skill.id) { params.skillId = this.object.skill.id; } else if (this.object.skill.cat) { params.skillCatId = this.object.skill.cat; } if (this.object.skill.name) { name = name + " - " + this.object.skill.name; } const command = `new game.l5r5e.DicePickerDialog(${JSON.stringify(params)}).render(true);`; let macro = game.macros.contents.find((m) => m.name === name && m.command === command && m.isAuthor); if (!macro) { macro = await Macro.create({ name, type: "script", scope: "global", command, img: this._actor?.img ? this._actor.img : "systems/l5r5e/assets/dices/default/ring_et.svg", }); } // Search if already in player hotbar if (Object.values(game.user.hotbar).includes(macro.id)) { return; } return game.user.assignHotbarMacro(macro, "auto"); // 1st available } /** * Return the token actor who have the min/max value for this property * @param {string} property Property name (vigilance, strife.value) * @param {boolean|null} isMin Null: single target, Min/Max: get the actor who have the max value * @return {TokenDocument|null} * @private */ static _getTargetTokenFromSelection(property, isMin = null) { if (game.user.targets.size < 1) { return null; } let targetToken; if (isMin === null) { // only one target, get the first element targetToken = Array.from(game.user.targets).values().next()?.value?.document; } else { // Group (Min/Max) const targetGrp = Array.from(game.user.targets).reduce( (acc, tgt) => { const targetActor = tgt.document.actor; if (!targetActor.isCharacterType) { return acc; } const targetData = targetActor.system; const value = targetActor[property] || targetData[property] || null; if (!value) { return acc; } if ((isMin && value < acc.value) || (!isMin && value > acc.value)) { acc.actor = tgt.document; acc.value = value; } return acc; }, { actor: null, value: 0 } ); targetToken = targetGrp.actor; } return targetToken; } /** * Parse the difficulty from technique * * Examples : * "@S:vigilance" * "@T:vigilance" * "@T:vigilance|min" * "@T:vigilance|max" * "@T:vigilance|max(statusRank)" * "@T:intrigueRank" * "@T:martialRank" * "@T:statusRank|max" * "@T:strife.value|max" * * @param {string|number} difficulty * @return {boolean} */ parseDifficulty(difficulty) { // Macro style if (!Number.isNumeric(difficulty) && difficulty.startsWith("@")) { // 0: "@T:vigilance|max(statusRank)" // 1: "T" // Meaning : S(elf), T(arget) // 2: "vigilance" // 3: "max" // 4: "statusRank" const infos = difficulty.match(CONFIG.l5r5e.regex.techniqueDifficulty); if (!infos) { console.log("L5R5E | DP | Fail to parse difficulty", difficulty); return false; } // Define which actor is needed for the difficulty let targetActor = null; let targetToken = null; if (infos[1] === "S") { targetActor = this._actor; } else if (game.user.targets.size > 0) { // Between the targets targetToken = DicePickerDialog._getTargetTokenFromSelection( infos[4] || infos[2], !infos[3] ? null : infos[3] === "min" ); if (targetToken) { targetActor = targetToken.actor; } } // Wrong syntax or no target set, do manual TN if (!targetActor) { console.log("L5R5E | DP | Fail to get actor from target selection, or no target selected"); return false; } // Check in actor. or actor.system. difficulty = targetActor[infos[2]] || targetActor.system[infos[2]] || null; if (difficulty < 1) { console.log("L5R5E | DP | Fail to parse difficulty from target"); return false; } // Before difficultyHiddenIsLock this.difficulty = difficulty; // Hide npc stats on target if (infos[1] === "T") { this.difficultyHidden = true; this._difficultyHiddenIsLock.option = true; this.target = targetToken; } return true; } // finally difficulty = parseInt(difficulty); if (isNaN(difficulty) || difficulty < 0) { return false; } this.difficulty = difficulty; return true; } /** * Parse Skills from technique * * Character : expand category (social) to it's skillname (command,courtesy...) * NPC : shrink to category names * * @param {string} skillList * @return {string[]} */ parseSkillsList(skillList) { const categories = game.l5r5e.HelpersL5r5e.getCategoriesSkillsList(); // Sanitize and uniques values const unqSkillList = new Set(); skillList.split(",").forEach((s) => { s = s.trim(); if (CONFIG.l5r5e.skills.has(s)) { unqSkillList.add(this.useCategory ? CONFIG.l5r5e.skills.get(s) : s); } else if (categories.has(s)) { if (this.useCategory) { unqSkillList.add(s); } else { categories.get(s).forEach((e) => unqSkillList.add(e)); } } }); // Sort by the translated label const array = [...unqSkillList].map((id) => { return { id: id, label: this.useCategory ? game.i18n.localize(`l5r5e.skills.${id}.title`) : game.i18n.localize(`l5r5e.skills.${CONFIG.l5r5e.skills.get(id)}.${id}`), }; }); array.sort((a, b) => a.label.localeCompare(b.label)); return array; } }