From 9219d0d9ca0856c0bde4ff346121cfc72908dd17 Mon Sep 17 00:00:00 2001 From: jalensailin Date: Fri, 25 Jul 2025 16:50:52 -0400 Subject: [PATCH] Improve / clean logic of applying skill improvements --- .eslintrc.yml | 1 + lang/en.json | 10 +- module/config.js | 12 + module/settings.js | 14 +- module/sheets/agent-sheet.js | 406 ++++++++++-------- module/sheets/base-sheet.js | 5 +- .../actor/partials/custom-skills-partial.html | 2 +- .../dialog/apply-skill-improvements.html | 7 + 8 files changed, 267 insertions(+), 190 deletions(-) create mode 100644 templates/dialog/apply-skill-improvements.html diff --git a/.eslintrc.yml b/.eslintrc.yml index 7001ee9..4bb014b 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -47,6 +47,7 @@ globals: ActiveEffect: readonly Actor: readonly CONFIG: readonly + CONST: readonly ChatMessage: readonly Combat: readonly Combatant: readonly diff --git a/lang/en.json b/lang/en.json index 8058879..ba0d360 100644 --- a/lang/en.json +++ b/lang/en.json @@ -86,11 +86,11 @@ "DG.Skills.AddTypedOrCustomSkill": "Add Typed/Custom Skill", "DG.Skills.AddSkill": "Add Skill", "DG.Skills.Apply": "Apply", - "DG.Skills.ApplySkillImprovements": "Apply Skill Improvements", - "DG.Skills.ApplySkillImprovementsChatFlavor": "Improved the following skills by", - "DG.Skills.ApplySkillImprovementsDialogLabel": "Improve skills by the following formula:", - "DG.Skills.ApplySkillImprovementsDialogEffectsFollowing": "Effects the following skills:", - "DG.Skills.ApplySkillImprovementsWarning": "No failed skills to improve.", + "DG.Skills.ApplySkillImprovements.Title": "Apply Skill Improvements", + "DG.Skills.ApplySkillImprovements.ChatFlavor": "Improved the following skills by {formula}:", + "DG.Skills.ApplySkillImprovements.DialogLabel": "Improve skills by the following formula: {formula}", + "DG.Skills.ApplySkillImprovements.DialogEffectsFollowing": "Effects the following skills: {skillList}", + "DG.Skills.ApplySkillImprovements.Warning": "No failed skills to improve.", "DG.Skills.EditTypedOrCustomSkill": "Edit Typed or Custom Skill", "DG.Skills.EditSkill": "Edit Skill", "DG.Skills.SkillGroup": "Skill Group", diff --git a/module/config.js b/module/config.js index 9e06eae..5e10940 100644 --- a/module/config.js +++ b/module/config.js @@ -50,8 +50,20 @@ const DG = /** @type {const} */ ({ "ritual", ], + skillImprovementFormulas: { + // If choices are defined, the resulting setting will be a select menu + 1: "DG.Settings.improvementroll.1", + d3: "DG.Settings.improvementroll.2", + d4: "DG.Settings.improvementroll.3", + "d4-1": "DG.Settings.improvementroll.4", + }, + // All the base rollable stats. statistics: ["str", "con", "dex", "int", "pow", "cha"], }); +// Set base template path for single source of truth for hbs locations. +const BASE_TEMPLATE_PATH = /** @type {const} */ (`systems/${DG.ID}/templates`); + export default DG; +export { BASE_TEMPLATE_PATH }; diff --git a/module/settings.js b/module/settings.js index 60352ef..3860ba6 100644 --- a/module/settings.js +++ b/module/settings.js @@ -1,3 +1,5 @@ +import DG from "./config.js"; + export default function registerSystemSettings() { game.settings.register("deltagreen", "characterSheetStyle", { name: game.i18n.localize("DG.Settings.charactersheet.name"), @@ -45,14 +47,8 @@ export default function registerSystemSettings() { scope: "world", // This specifies a world-stored setting config: true, // This specifies that the setting appears in the configuration view type: String, - choices: { - // If choices are defined, the resulting setting will be a select menu - 1: game.i18n.localize("DG.Settings.improvementroll.1"), - "1d3": game.i18n.localize("DG.Settings.improvementroll.2"), - "1d4": game.i18n.localize("DG.Settings.improvementroll.3"), - "1d4-1": game.i18n.localize("DG.Settings.improvementroll.4"), - }, - default: "1d4", // The default value for the setting, per the most recent errata. + choices: DG.skillImprovementFormulas, + default: "d4", // The default value for the setting, per the most recent errata. onChange: (value) => { // A callback function which triggers when the setting is changed // console.log(value) @@ -70,7 +66,7 @@ export default function registerSystemSettings() { requiresReload: true, type: Boolean, default: false, - } + }, ); game.settings.register("deltagreen", "showImpossibleLandscapesContent", { diff --git a/module/sheets/agent-sheet.js b/module/sheets/agent-sheet.js index 4d713b7..a9f4aad 100644 --- a/module/sheets/agent-sheet.js +++ b/module/sheets/agent-sheet.js @@ -1,5 +1,8 @@ +import DG, { BASE_TEMPLATE_PATH } from "../config.js"; import DGActorSheet from "./base-actor-sheet.js"; +const { renderTemplate } = foundry.applications.handlebars; + /** @extends {DGActorSheet} */ export default class DGAgentSheet extends DGActorSheet { /** @override */ @@ -9,7 +12,7 @@ export default class DGAgentSheet extends DGActorSheet { clearBondDamage: DGAgentSheet._clearBondDamage, resetBreakingPoint: DGAgentSheet._resetBreakingPoint, // Other actions. - applySkillImprovements: DGAgentSheet._applySkillImprovements, + applySkillImprovements: DGAgentSheet._processSkillImprovements, }, }); @@ -84,75 +87,6 @@ export default class DGAgentSheet extends DGActorSheet { /* -------------------------------------------- */ - static _applySkillImprovements(event, target) { - const failedSkills = Object.entries(this.actor.system.skills).filter( - (skill) => skill[1].failure, - ); - const failedTypedSkills = Object.entries( - this.actor.system.typedSkills, - ).filter((skill) => skill[1].failure); - if (failedSkills.length === 0 && failedTypedSkills.length === 0) { - ui.notifications.warn("No Skills to Increase"); - return; - } - - let htmlContent = ""; - let failedSkillNames = ""; - failedSkills.forEach(([skill], value) => { - if (value === 0) { - failedSkillNames += game.i18n.localize(`DG.Skills.${skill}`); - } else { - failedSkillNames += `, ${game.i18n.localize(`DG.Skills.${skill}`)}`; - } - }); - failedTypedSkills.forEach(([skillName, skillData], value) => { - if (value === 0 && failedSkillNames === "") { - failedSkillNames += `${game.i18n.localize( - `DG.TypeSkills.${skillData.group.split(" ").join("")}`, - )} (${skillData.label})`; - } else { - failedSkillNames += `, ${game.i18n.localize( - `DG.TypeSkills.${skillData.group.split(" ").join("")}`, - )} (${skillData.label})`; - } - }); - - const baseRollFormula = game.settings.get( - "deltagreen", - "skillImprovementFormula", - ); - - htmlContent += `
`; - htmlContent += ` `; - htmlContent += `
`; - htmlContent += ` ${game.i18n.localize( - "DG.Skills.ApplySkillImprovementsDialogEffectsFollowing", - )} ${failedSkillNames} `; - htmlContent += `
`; - - new Dialog({ - content: htmlContent, - title: - game.i18n.translations.DG?.Skills?.ApplySkillImprovements ?? - "Apply Skill Improvements", - default: "add", - buttons: { - apply: { - label: game.i18n.translations.DG?.Skills?.Apply ?? "Apply", - callback: (btn) => { - this._applySkillImprovements( - baseRollFormula, - failedSkills, - failedTypedSkills, - ); - }, - }, - }, - }).render(true); - } - /** Resets the actor's current breaking point based on their sanity and POW statistics. */ static _resetBreakingPoint() { const systemData = this.actor.system; @@ -168,115 +102,217 @@ export default class DGAgentSheet extends DGActorSheet { _resetBreakingPoint(event) { event.preventDefault(); - let currentBreakingPoint = 0; + const currentBreakingPoint = Math.max( + this.actor.system.sanity.value - this.actor.system.statistics.pow.value, + 0, + ); - currentBreakingPoint = - this.actor.system.sanity.value - this.actor.system.statistics.pow.value; - - if (currentBreakingPoint < 0) { - currentBreakingPoint = 0; - } - - const updatedData = foundry.utils.duplicate(this.actor.system); - - updatedData.sanity.currentBreakingPoint = currentBreakingPoint; - - this.actor.update({ system: updatedData }); + this.actor.update({ + "system.sanity.currentBreakingPoint": currentBreakingPoint, + }); } - // For any skills a user has checked off as failed, roll the improvement and update the agent's skills to their new values - async _applySkillImprovements( - baseRollFormula, + /** + * Runs through the whole process of improving skills, + * i.e., prompting the user, rolling, and creating the chat card. + * + * @returns {Promise} + */ + static async _processSkillImprovements() { + const { skills, typedSkills } = this.actor.system; + + const failedSkills = Object.values(skills).filter((skill) => skill.failure); + const failedTypedSkills = Object.values(typedSkills).filter( + (skill) => skill.failure, + ); + + if (failedSkills.length + failedTypedSkills.length === 0) { + ui.notifications.warn("DG.Skills.ApplySkillImprovements.Warning", { + localize: true, + }); + return null; + } + + const baseRollFormula = game.settings.get(DG.ID, "skillImprovementFormula"); + + const prompt = await DGAgentSheet._createSkillImprovementDialog( + baseRollFormula, + failedSkills, + failedTypedSkills, + ); + + if (!prompt) return null; + + const { roll, resultObj } = await this._createSkillImprovementRolls( + baseRollFormula, + failedSkills, + failedTypedSkills, + ); + + await this._createSkillImprovementChatCard( + failedSkills, + failedTypedSkills, + roll, + resultObj, + ); + + return this._applySkillImprovements( + failedSkills, + failedTypedSkills, + resultObj, + ); + } + + /** + * A map of skill keys -> number to improve them by + * @typedef {Object} ResultObj + */ + + /** + * @typedef {Object} FailedSkill + */ + + /** + * @typedef {FailedSkill} FailedTypedSkill + */ + + /** + * The formula used to calculate skill improvements + * Note. There is not a leading number of dice here, just 1 or dX-Y. + * @typedef {"1"|"d3"|"d4"|"d4-1"} SkillImprovementFormula + */ + + /** + * Creates and displays a dialog to approve applying skill improvements to failed skills. + * + * @param {SkillImprovementFormula} baseFormula - The formula used to calculate skill improvements. + * @param {FailedSkill[]} failedSkills - An array of failed skills. + * @param {FailedTypedSkill[]} failedTypedSkills - An array of failed typed skills. + * + * @returns {Promise} - A promise that resolves to `true` if accepted, `false` otherwise. + */ + static async _createSkillImprovementDialog( + baseFormula, failedSkills, failedTypedSkills, ) { - const actorData = this.actor.system; - const resultList = []; - let rollFormula; + const localizedFailedSkills = failedSkills.map((skill) => + game.i18n.localize(`DG.Skills.${skill.key}`), + ); - // Define the amount of dice being rolled, if any. - switch (baseRollFormula) { - case "1": - rollFormula = 1; - break; - case "1d3": - rollFormula = `${failedSkills.length + failedTypedSkills.length}d3`; - break; - case "1d4": - case "1d4-1": - rollFormula = `${failedSkills.length + failedTypedSkills.length}d4`; - break; - default: + const localizedFailedTypedSkills = failedTypedSkills.map((skill) => { + const groupKey = `DG.TypeSkills.${skill.group.replace(/\s+/g, "")}`; + const groupLabel = game.i18n.localize(groupKey); + return `${groupLabel} (${skill.label})`; + }); + + const failedSkillNames = [ + ...localizedFailedSkills, + ...localizedFailedTypedSkills, + ].join(", "); + + const content = await renderTemplate( + `${BASE_TEMPLATE_PATH}/dialog/apply-skill-improvements.html`, + { + failedSkillNames, + baseFormula: `${baseFormula === "1" ? "1" : `1${baseFormula}`}%`, + }, + ); + + return Dialog.wait({ + content, + title: game.i18n.localize("DG.Skills.ApplySkillImprovements.Title"), + default: "apply", + buttons: { + apply: { + label: game.i18n.localize("DG.Skills.Apply"), + icon: "", + }, + }, + }); + } + + /** + * Generates and evaluates the rolls for skill improvements based on failed skills. + * + * @param {SkillImprovementFormula} baseFormula - The formula used to calculate skill improvements. + * @param {FailedSkill[]} failedSkills - An array of failed skills. + * @param {FailedTypedSkill[]} failedTypedSkills - An array of failed typed skills. + * + * @returns {Promise<{roll: Roll|undefined, resultObj: ResultObj}>} - An object containing the roll result and a map of skill keys to improvement values. + * + * @throws {Error} - Throws an error if the baseFormula is unknown. + */ + async _createSkillImprovementRolls( + baseFormula, + failedSkills, + failedTypedSkills, + ) { + const totalFailures = failedSkills.length + failedTypedSkills.length; + + if (!Object.keys(DG.skillImprovementFormulas).includes(baseFormula)) { + throw new Error(`Unknown roll formula: ${baseFormula}`); } + const rollFormula = + baseFormula === "1" ? "1" : `${totalFailures}${baseFormula}`; + let roll; - if (rollFormula !== 1) { - roll = new Roll(rollFormula, actorData); - await roll.evaluate(); - // Put the results into a list. - roll.terms[0].results.forEach((result) => - resultList.push( - baseRollFormula === "1d4-1" ? result.result - 1 : result.result, - ), - ); + const resultObj = {}; + if (rollFormula !== "1") { + roll = await new Roll(rollFormula, this.actor.system).evaluate(); + [...failedSkills, ...failedTypedSkills].forEach((skill, index) => { + const { result } = roll.terms[0].results[index]; + resultObj[skill.key] = result; + }); } - // This will be end up being a list of skills and how much each were improved by. It gets modified in the following loops. - let improvedSkillList = ""; + return { roll, resultObj }; + } - // Get copy of current system data, will update this and then apply all changes at once synchronously at the end. - const updatedData = foundry.utils.duplicate(actorData); + /** + * Create a chat card to record skill improvements. + * + * @param {FailedSkill[]} failedSkills - array of failed skills + * @param {FailedTypedSkill[]} failedTypedSkills - array of failed typed skills + * @param {Roll|undefined} roll - the improvement roll + * @param {ResultObj} resultObj + * + * @returns {Promise} + */ + _createSkillImprovementChatCard( + failedSkills, + failedTypedSkills, + roll, + resultObj, + ) { + const localizeFailedSkills = (skillsArray) => { + return skillsArray.map((skill) => { + const increment = resultObj[skill.key] ?? 1; + const label = + skill.label ?? game.i18n.localize(`DG.Skills.${skill.key}`); // fallback for regular skills + const groupLabel = skill.group + ? `${game.i18n.localize( + `DG.TypeSkills.${skill.group.replace(/\s+/g, "")}`, + )} (${label})` + : label; - failedSkills.forEach(([skill], value) => { - updatedData.skills[skill].proficiency += resultList[value] ?? 1; // Increase proficiency by die result or by 1 if there is no dice roll. - updatedData.skills[skill].failure = false; + return `${groupLabel}: +${increment}%`; + }); + }; - // So we can record the regular skills improved and how much they were increased by in chat. - // The if statement tells us whether to add a comma before the term or not. - if (value === 0) { - improvedSkillList += `${game.i18n.localize( - `DG.Skills.${skill}`, - )}: +${resultList[value] ?? 1}%`; - } else { - improvedSkillList += `, ${game.i18n.localize( - `DG.Skills.${skill}`, - )}: +${resultList[value] ?? 1}%`; - } - }); + const failedSkillNames = localizeFailedSkills(failedSkills); + const failedTypedSkillNames = localizeFailedSkills(failedTypedSkills); - failedTypedSkills.forEach(([skillName, skillData], value) => { - // We must increase value in the following line by the length of failedSkills, so that we index the entire resultList. - // Otherwise we would be adding the same die results to regular skills and typed skills. - updatedData.typedSkills[skillName].proficiency += - resultList[value + failedSkills.length] ?? 1; - updatedData.typedSkills[skillName].failure = false; - - // So we can record the typed skills improved and how much they were increased by in chat. - // The if statement tells us whether to add a comma before the term or not. - if (value === 0 && improvedSkillList === "") { - improvedSkillList += `${game.i18n.localize( - `DG.TypeSkills.${skillData.group.split(" ").join("")}`, - )} (${skillData.label}): +${ - resultList[value + failedSkills.length] ?? 1 - }%`; - } else { - improvedSkillList += `, ${game.i18n.localize( - `DG.TypeSkills.${skillData.group.split(" ").join("")}`, - )} (${skillData.label}): +${ - resultList[value + failedSkills.length] ?? 1 - }%`; - } - }); - - // Probably not worth triggering the update if the user didn't pick any skills - if (improvedSkillList !== "") { - await this.actor.update({ system: updatedData }); - } - - let html; - html = `
`; - html += `
${improvedSkillList}
`; - html += `
`; + // Prepare chat data + const content = [...failedSkillNames, ...failedTypedSkillNames].join(", "); + const flavor = game.i18n.format( + "DG.Skills.ApplySkillImprovements.ChatFlavor", + { formula: `${roll.formula?.replace(/^.*d/, "1d") ?? "1"}%` }, // formula = 1, otherwise 1dX-Y (i.e. 1d3, 1d4, 1d4-1) + ); + const type = roll + ? CONST.CHAT_MESSAGE_TYPES.ROLL + : CONST.CHAT_MESSAGE_TYPES.OTHER; const chatData = { speaker: ChatMessage.getSpeaker({ @@ -284,19 +320,45 @@ export default class DGAgentSheet extends DGActorSheet { token: this.token, alias: this.actor.name, }), - content: html, - flavor: `${game.i18n.localize( - "DG.Skills.ApplySkillImprovementsChatFlavor", - )} +${baseRollFormula}%:`, - type: baseRollFormula === "1" ? 0 : 5, // 0 = CHAT_MESSAGE_TYPES.OTHER, 5 = CHAT_MESSAGE_TYPES.ROLL - rolls: baseRollFormula === "1" ? [] : [roll], // If adding flat +1, there is no roll. + content, + flavor, + type, + rolls: roll ? [roll] : [], rollMode: game.settings.get("core", "rollMode"), }; - // Create a message from this roll, if there is one. if (roll) return roll.toMessage(chatData); + return ChatMessage.create(chatData); + } - // If no roll, create a chat message directly. - return ChatMessage.create(chatData, {}); + /** + * Updates the actor's skills / typed skills with the improvements, + * persisting the changes to the database. + * + * @param {FailedSkill[]} failedSkills - array of failed skills that need to be updated + * @param {FailedTypedSkill[]} failedTypedSkills - array of failed skills that need to be updated + * @param {ResultObj} resultObj + * @returns {Promise} - the update promise + */ + _applySkillImprovements(failedSkills, failedTypedSkills, resultObj) { + const updateSkills = (skillsArray, updatedTarget) => { + skillsArray.forEach((skill) => { + const increment = resultObj[skill.key] ?? 1; + updatedTarget[skill.key].proficiency += increment; + updatedTarget[skill.key].failure = false; + }); + }; + + const actorData = this.actor.system; + // Get data and update it. + const updatedSkills = foundry.utils.duplicate(actorData.skills); + const updatedTypedSkills = foundry.utils.duplicate(actorData.typedSkills); + updateSkills(failedSkills, updatedSkills); + updateSkills(failedTypedSkills, updatedTypedSkills); + + // Send updates to database. + return this.actor.update({ + system: { skills: updatedSkills, typedSkills: updatedTypedSkills }, + }); } } diff --git a/module/sheets/base-sheet.js b/module/sheets/base-sheet.js index de42d33..f4de812 100644 --- a/module/sheets/base-sheet.js +++ b/module/sheets/base-sheet.js @@ -1,4 +1,4 @@ -import DG from "../config.js"; +import DG, { BASE_TEMPLATE_PATH } from "../config.js"; const HbsAppMixin = foundry.applications.api.HandlebarsApplicationMixin; @@ -11,8 +11,7 @@ const DGSheetMixin = (Base) => { form: { submitOnChange: true }, }); - /** @inheritdoc */ - static TEMPLATE_PATH = /** @type {const} */ (`systems/${DG.ID}/templates`); + static TEMPLATE_PATH = BASE_TEMPLATE_PATH; static get THEME() { return game.settings.get(DG.ID, "characterSheetStyle"); diff --git a/templates/actor/partials/custom-skills-partial.html b/templates/actor/partials/custom-skills-partial.html index 4d01753..fd0df26 100644 --- a/templates/actor/partials/custom-skills-partial.html +++ b/templates/actor/partials/custom-skills-partial.html @@ -14,7 +14,7 @@ {{#if_eq actor.type "agent" }} - {{localize 'DG.Skills.ApplySkillImprovements'}} + {{localize 'DG.Skills.ApplySkillImprovements.Title'}} {{/if_eq}} diff --git a/templates/dialog/apply-skill-improvements.html b/templates/dialog/apply-skill-improvements.html new file mode 100644 index 0000000..213281f --- /dev/null +++ b/templates/dialog/apply-skill-improvements.html @@ -0,0 +1,7 @@ +
+ +
+ + {{{localize "DG.Skills.ApplySkillImprovements.DialogEffectsFollowing" skillList=failedSkillNames}}} + +