export class VermineUtils { /** * Rolls dice with Vermine2047-specific rules. * @param {Object} options - Roll options * @param {Actor} options.actor - The actor rolling * @param {number} options.NoD - Base dice pool * @param {number} [options.Reroll=0] - Reroll count * @param {number} [options.difficulty=7] - Difficulty threshold * @param {number} [options.self_control=0] - Self control used * @param {string} [options.rollLabel="jet custom"] - Roll label * @param {Object} [options.totems={}] - Totems used {human: boolean, adapted: boolean} * @param {number} [options.max_effort=0] - Max effort * @param {string} [options.skillCategory=null] - Skill category for domain bonuses * @param {string} [options.keepTotem=null] - Totem to keep ('human' or 'adapted') * @param {number} [options.skillLevel=null] - Skill level for auto-successes * @param {boolean} [options.hasSpecialty=false] - Whether a specialty is used * @returns {Promise} The roll result */ static async roll({ actor, NoD, Reroll = 0, difficulty = 7, self_control = 0, rollLabel = "jet custom", totems = { human: false, adapted: false }, max_effort = 0, skillCategory = null, keepTotem = null, skillLevel = null, hasSpecialty = false }) { // Validate inputs if (!actor) { throw new Error("Actor is required for rolling"); } // Sanitize user name for use in dice flavor const safeUserName = (game.user?.name ?? "user").replace(/[^a-zA-Z0-9_]/g, '_'); // Declare variables let formula = ""; let modFormula = null; let totemBonus = { human: 0, adapted: 0 }; // Calculate domain bonuses for totems if (skillCategory) { totemBonus = this._calculateTotemDomainBonuses(skillCategory, actor); } // Apply auto-successes and auto-thresholds let autoSuccesses = 0; let adjustedDifficulty = difficulty; if (skillLevel !== null && skillLevel !== undefined) { autoSuccesses = this._calculateAutoSuccesses(skillLevel, hasSpecialty); const autoThreshold = this._getAutoThreshold(skillLevel); if (autoThreshold !== null) { adjustedDifficulty = autoThreshold; } } // Handle human totem if (totems.human) { NoD--; const humanDifficulty = skillLevel !== null ? Math.max(adjustedDifficulty, difficulty) : adjustedDifficulty; const humanFormula = `(1D10cs>=${humanDifficulty}[human_${safeUserName}]*2)`; // Apply domain bonus/malus if (totemBonus.human !== 0) { NoD += totemBonus.human; } modFormula = humanFormula; } // Handle adapted totem if (totems.adapted) { NoD--; const adaptedDifficulty = skillLevel !== null ? Math.max(adjustedDifficulty, difficulty) : adjustedDifficulty; const adaptedFormula = `(1D10cs>=${adaptedDifficulty}[adapted_${safeUserName}]*2)`; // Apply domain bonus/malus if (totemBonus.adapted !== 0) { NoD += totemBonus.adapted; } // Build combined formula if (modFormula !== null) { modFormula = `${modFormula}+${adaptedFormula}`; } else { modFormula = adaptedFormula; } } // Handle keepTotem selection (if both totems are active) if (totems.human && totems.adapted && keepTotem) { if (keepTotem === 'human' && totems.adapted) { modFormula = `(1D10cs>=${adjustedDifficulty}[human_${safeUserName}]*2)`; NoD++; // Cancel the decrement for adapted } else if (keepTotem === 'adapted' && totems.human) { modFormula = `(1D10cs>=${adjustedDifficulty}[adapted_${safeUserName}]*2)`; NoD++; // Cancel the decrement for human } } // Build base formula const baseFormula = `${NoD}d10cs>=${adjustedDifficulty}[regular_${safeUserName}]`; // Build final formula formula = modFormula !== null ? `${baseFormula}+${modFormula}` : baseFormula; // Create the roll const roll = new Roll(formula, actor.getRollData()); // Store metadata for display roll.vermineData = { totemsUsed: { ...totems }, keepTotem: keepTotem, difficulty: adjustedDifficulty, originalDifficulty: difficulty, skillCategory: skillCategory, skillLevel: skillLevel, hasSpecialty: hasSpecialty, autoSuccesses: autoSuccesses, totemBonuses: { ...totemBonus }, baseNoD: NoD, rerolls: Reroll, selfControl: self_control }; // Evaluate the roll await roll.evaluate(); // Show 3D dice if available await VermineUtils.showDiceSoNice(roll); // Display result in chat VermineUtils.diplayChatRoll(roll, { actor, NoD, Reroll, difficulty, self_control, rollLabel, totems, max_effort, skillCategory, keepTotem, skillLevel, hasSpecialty }); return roll; } /** * Calculates domain bonuses/penalties for totems. * @param {string} skillCategory - The skill category * @param {Actor} actor - The actor * @returns {Object} Bonuses for each totem {human: number, adapted: number} */ static _calculateTotemDomainBonuses(skillCategory, actor) { const bonuses = { human: 0, adapted: 0 }; // Validate inputs if (!CONFIG.VERMINE?.totemDomains || !actor?.system?.identity?.totem) { return bonuses; } const actorTotem = actor.system.identity.totem; // Check if actor's totem exists in configuration if (!CONFIG.VERMINE.totemDomains[actorTotem]) { return bonuses; } const totemConfig = CONFIG.VERMINE.totemDomains[actorTotem]; if (!totemConfig?.domains) { return bonuses; } // Get actor's preferred skill category const preferredCategory = actor.system.skill_categories?.preferred; // Bonus for actor's totem if preferred category is in its domains if (preferredCategory && totemConfig.domains.includes(preferredCategory)) { bonuses[actorTotem] = totemConfig.bonus || 1; } // Penalty for opposite totem if preferred category is in its domains const oppositeTotem = CONFIG.VERMINE.totem_opposites?.[actorTotem]; if (oppositeTotem && CONFIG.VERMINE.totemDomains[oppositeTotem]) { const oppositeConfig = CONFIG.VERMINE.totemDomains[oppositeTotem]; if (preferredCategory && oppositeConfig?.domains?.includes(preferredCategory)) { bonuses[oppositeTotem] = -(oppositeConfig.bonus || 1); } } return bonuses; } /** * Calculates automatic successes based on skill mastery level. * @param {number} skillLevel - Skill level (0-5) * @param {boolean} [hasSpecialty=false] - Whether a specialty is used * @returns {number} Number of automatic successes */ static _calculateAutoSuccesses(skillLevel, hasSpecialty = false) { // According to Vermine2047 rules, automatic successes are based on mastery level: // Level 0 (Incompetent): 0 automatic successes // Level 1 (Beginner): 0 automatic successes // Level 2 (Proficient): 1 automatic success if specialty is used // Level 3 (Expert): 1 automatic success // Level 4 (Master): 1 automatic success + 1 if specialty is used // Level 5 (Legend): 2 automatic successes if (!skillLevel) return 0; let autoSuccesses = 0; switch (skillLevel) { case 2: // Compétent if (hasSpecialty) autoSuccesses = 1; break; case 3: // Expert autoSuccesses = 1; break; case 4: // Maître autoSuccesses = 1; if (hasSpecialty) autoSuccesses += 1; break; case 5: // Légende autoSuccesses = 2; break; default: autoSuccesses = 0; } return autoSuccesses; } /** * Determines the automatic threshold if the skill is not mastered. * @param {number} skillLevel - Skill level * @returns {number|null} Automatic threshold or null if skill is mastered */ static _getAutoThreshold(skillLevel) { // If the skill is not mastered (level 0 or 1), use a default threshold // Level 0 (Incompetent): threshold = 9 (very hard) // Level 1 (Beginner): threshold = 7 (hard) // Level >= 2: null (use normal threshold) if (skillLevel === 0) return 9; // Very hard if (skillLevel === 1) return 7; // Hard return null; // Utiliser le seuil normal } /** * Handles reroll events on dice in chat messages. * @param {Object} message - The chat message containing the reroll event * @param {Object} ev - The reroll event * @returns {Promise} Whether the reroll was successful */ static async onReroll(message, ev) { // Verify user permissions const msgUserId = message.user?.id ?? message.user; if (msgUserId !== game.user?.id && !game.user?.isGM) { ui.notifications.warn(game.i18n.localize('VERMINE.error_cannot_reroll')); return false; } // Get reroll count const rollMessage = ev.currentTarget.closest('div.vermine-roll-message'); if (!rollMessage) { return false; } let rerollCount = rollMessage.querySelector('#allowed_reroll')?.innerText; // Check if rerolls are available if (!rerollCount || parseInt(rerollCount, 10) < 1) { ui.notifications.warn(game.i18n.localize('VERMINE.error_no_rerolls_left')); const rerollables = ev.currentTarget.closest('ul')?.querySelectorAll('.rerollable'); if (rerollables) { rerollables.forEach(el => el.classList.remove('rerollable')); } return false; } ev.currentTarget.classList.add('rerolled'); // Set reroll flag await message.setFlag("world", "reroll", true); // Get difficulty and dice type const ulElement = ev.currentTarget.closest('ul'); const difficulty = ulElement?.dataset.difficulty ?? 7; let diceType = ev.currentTarget.dataset.diceType; // Sanitize user name const safeUserName = (game.user?.name ?? "user").replace(/[^a-zA-Z0-9_]/g, '_'); // Build reroll formula let formula = `1d10cs>=${difficulty}`; switch ((diceType ?? '').trim()) { case 'human': formula = `(1d10cs>=${difficulty}[human_${safeUserName}])*2`; break; case 'adapted': formula = `(1d10cs>=${difficulty}[adapted_${safeUserName}])*2`; break; default: formula += `[regular_${safeUserName}]`; break; } // Create and evaluate reroll const reroll = new Roll(formula); await reroll.evaluate(); // Show 3D dice if available await VermineUtils.showDiceSoNice(reroll); // Update die display const result = reroll.dice[0]?.results[0]?.result ?? 0; const dieSpan = ev.currentTarget.querySelector('span'); if (dieSpan) { dieSpan.innerText = result; } // Update total if successful const success = reroll.dice[0]?.results[0]?.success; if (success) { ev.currentTarget.classList.add('success'); const totalElement = rollMessage.querySelector('#total'); if (totalElement) { const currentTotal = parseInt(totalElement.innerText, 10) || 0; totalElement.innerText = currentTotal + reroll.total; } } // Update message content ev.currentTarget.classList.remove("rerollable"); const messageContent = ev.currentTarget.closest('div.message-content'); if (messageContent) { const newRerollCount = parseInt(rerollCount, 10) - 1; rollMessage.querySelector('#allowed_reroll').innerText = newRerollCount; await message.update({ content: messageContent.outerHTML }); } return true; } /** * Sets up event listeners for chat messages. * @param {HTMLElement} html - The HTML element containing chat events. */ static async chatListenners(html) { // Ensure html is a jQuery object const $html = $(html); // Get reroll count const rerollCountElement = $html.find('#allowed_reroll')[0]; const rerollCount = rerollCountElement?.innerText; // Enable/disable rerolls based on count if (!rerollCount || parseInt(rerollCount, 10) < 1) { // Disable rerolls for all dice $html.find('.die').each(function() { this.classList.remove("rerollable"); }); } else { // Enable rerolls for all dice $html.find('.die').each(function() { this.classList.add("rerollable"); }); } // Add click event for rerollable dice $html.find('.rerollable').click(async (ev) => { ev.preventDefault(); const msgId = ev.currentTarget.closest("li.message")?.dataset?.messageId; if (msgId) { const message = await game.messages.get(msgId); await VermineUtils.onReroll(message, ev); } }); // Update granted reroll label $html.find("#effort-reroll").change(ev => { const label = $html.find("#granted-reroll")[0]; if (label) { label.innerText = ev.currentTarget.value; } }); // Add click event for granting rerolls $html.find("button.grant-reroll").click(async (ev) => { const grantedRerollElement = $html.find('#granted-reroll')[0]; const allowedRerollElement = $html.find("#allowed_reroll")[0]; if (grantedRerollElement && allowedRerollElement) { allowedRerollElement.innerText = grantedRerollElement.innerText; } const mesEl = ev.currentTarget.closest('[data-message-id]'); const messageId = mesEl?.dataset?.messageId; if (messageId) { // Hide reroll grant area ev.currentTarget.closest('.reroll-from-effort').style.display = "none"; const rollMessage = ev.currentTarget.closest(".vermine-roll-message"); if (rollMessage) { const content = rollMessage.outerHTML; const message = await game.messages.get(messageId); await message.update({ content: content }); } } }); } /** * Displays dice rolls in 3D if available. * @param {Roll} roll - The roll to display * @param {string} [rollMode] - The roll mode (uses game settings if not provided) * @returns {Promise} Whether 3D dice were shown */ static async showDiceSoNice(roll, rollMode) { if (!game.dice3d) { return false; } rollMode = rollMode ?? game.settings.get("core", "rollMode"); let whisper = null; let blind = false; switch (rollMode) { case "blindroll": // GM only blind = true; // Falls through case "gmroll": // GM + rolling player whisper = this.getUsers(user => user.isGM); break; case "roll": // Everybody whisper = this.getUsers(user => user.active); break; case "selfroll": whisper = [game.user.id]; break; } await game.dice3d.showForRoll(roll, game.user, true, whisper, blind); return true; } /** * Displays a dice roll in the chat. * @param {Roll} roll - The roll to display * @param {Object} param - Roll parameters * @returns {Promise} The created chat message */ static async diplayChatRoll(roll, param) { const content = await foundry.applications.handlebars.renderTemplate("systems/vermine2047/templates/roll-message.hbs", { roll, param }); const chatData = { user: game.user?._id, speaker: ChatMessage.getSpeaker(), content: content, roll: roll }; const msg = await ChatMessage.create(chatData); await msg.setFlag('world', 'roll', roll); return msg; } }