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, handicap = 0, bonusSuccesses = 0, poolBreakdown = null, specialtyName = null, weapon = null, targets = [], attack = null, messageFlags = null }) { // 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 automatic successes from skill mastery let adjustedDifficulty = difficulty; // Handle human totem if (totems.human) { NoD--; const humanDifficulty = 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 = 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 } } // Ensure the dice pool cannot go below zero NoD = Math.max(0, NoD) // Build base formula const baseFormula = `${NoD}d10cs>=${adjustedDifficulty}[regular_${safeUserName}]`; // Build final formula formula = modFormula !== null ? `${baseFormula}+${modFormula}` : baseFormula; // Reconcile the breakdown displayed in chat with the dice actually rolled. // Domain bonuses/malus are only applied when the corresponding totem die // is used, and totem dice replace (not add to) base-pool dice. if (poolBreakdown) { // Count totem dice present in the final modifier formula. let totemDice = 0; if (modFormula) { if (modFormula.includes("human_")) totemDice += 1; if (modFormula.includes("adapted_")) totemDice += 1; } // Only count the domain bonus of totems actually activated in the roll. const domainBonus = (totems.human ? totemBonus.human : 0) + (totems.adapted ? totemBonus.adapted : 0); const basePool = poolBreakdown.total; poolBreakdown.totemDice = totemDice; poolBreakdown.domainBonus = domainBonus; // Total = nombre réel de dés lancés (NoD réguliers + dés de totem). // Équivaut à basePool + domainBonus sauf si le pool régulier clampe à 0. poolBreakdown.total = NoD + totemDice; } // 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, totemBonuses: { ...totemBonus }, baseNoD: NoD, rerolls: Reroll, selfControl: self_control, bonusSuccesses: bonusSuccesses }; // 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, handicap, bonusSuccesses, poolBreakdown, specialtyName, weapon, targets, attack, messageFlags }); 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; } /** * Handles reroll events on dice in chat messages. * @param {Object} message - The chat message containing the reroll event * @param {HTMLElement} target - The clicked die element * @returns {Promise} Whether the reroll was successful */ static async onReroll(message, target) { // 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 = target.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 = target.closest('ul')?.querySelectorAll('.rerollable'); if (rerollables) { rerollables.forEach(el => el.classList.remove('rerollable')); } return false; } target.classList.add('rerolled'); // Set reroll flag await message.setFlag("world", "reroll", true); // Get difficulty and dice type const ulElement = target.closest('ul'); const difficulty = ulElement?.dataset.difficulty ?? 7; let diceType = target.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 = target.querySelector('span'); if (dieSpan) { dieSpan.innerText = result; } // Update the face used for the CSS background image. target.dataset.result = result; // Set/clear the success class from the rerolled die (a previous success // must be removed if the new result fails). const success = reroll.dice[0]?.results[0]?.success; target.classList.toggle('success', !!success); target.classList.remove("rerollable"); // Recompute the total from the live dice in the DOM. This avoids the // cumulative-increment drift of the old logic (which never removed a // removed success from #total). Totem dice (human/adapted) count double. const totalEl = rollMessage.querySelector('#total'); if (totalEl) { let total = 0; rollMessage.querySelectorAll('li.die.success').forEach(die => { const isTotem = die.classList.contains('human') || die.classList.contains('adapted'); total += isTotem ? 2 : 1; }); // Réussites automatiques (ex. bonus de Réaction) : ajoutées au total. const freeSuccesses = parseInt(rollMessage.dataset?.freeSuccesses ?? '0', 10) || 0; total += freeSuccesses; totalEl.innerText = total; } // Update message content const messageContent = target.closest('div.message-content'); if (messageContent) { const newRerollCount = parseInt(rerollCount, 10) - 1; rollMessage.querySelector('#allowed_reroll').innerText = newRerollCount; // Recompute the verdict banner from the recomputed total. const verdictEl = rollMessage.querySelector('.roll-verdict'); const requiredEl = rollMessage.querySelector('#required'); if (verdictEl && totalEl && requiredEl) { const total = parseInt(totalEl.innerText, 10) || 0; const required = parseInt(requiredEl.innerText, 10) || 1; const won = total >= required; verdictEl.classList.toggle('success', won); verdictEl.classList.toggle('failure', !won); const label = won ? game.i18n.localize('VERMINE.roll_success') : game.i18n.localize('VERMINE.roll_failure'); verdictEl.innerHTML = `${won ? '' : ''}${label}`; } // Recompute the dice summary from the current die list. const summaryEl = rollMessage.querySelector('.roll-dice-summary'); if (summaryEl) { const stats = { regular: { count: 0, success: 0 }, human: { count: 0, success: 0 }, adapted: { count: 0, success: 0 } }; rollMessage.querySelectorAll('li.die').forEach(die => { const type = die.classList.contains('human') ? 'human' : die.classList.contains('adapted') ? 'adapted' : 'regular'; stats[type].count += 1; if (die.classList.contains('success')) stats[type].success += 1; }); const mk = (key, count, s) => count ? `${count} ${game.i18n.localize(`VERMINE.die_${key}`)} · ${s} ${game.i18n.localize('VERMINE.die_successes')}` : ''; summaryEl.innerHTML = mk('regular', stats.regular.count, stats.regular.success) + mk('human', stats.human.count, stats.human.success) + mk('adapted', stats.adapted.count, stats.adapted.success); } 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) { // Get reroll count const rerollCountElement = html.querySelector('#allowed_reroll'); const rerollCount = rerollCountElement?.innerText; // Enable/disable rerolls based on count const dieClass = !rerollCount || parseInt(rerollCount, 10) < 1 ? 'remove' : 'add'; html.querySelectorAll('.die').forEach(el => el.classList[dieClass]("rerollable")); // Add click event for rerollable dice html.querySelectorAll('.rerollable').forEach(el => { el.addEventListener('click', async (ev) => { ev.preventDefault(); const dieEl = ev.currentTarget; const msgId = dieEl.closest("li.message")?.dataset?.messageId; if (msgId) { const message = await game.messages.get(msgId); await VermineUtils.onReroll(message, dieEl); } }); }); // Update granted reroll label const effortReroll = html.querySelector("#effort-reroll"); if (effortReroll) { effortReroll.addEventListener('change', ev => { const label = html.querySelector("#granted-reroll"); if (label) { label.innerText = ev.currentTarget.value; } }); } // Add click event for granting rerolls html.querySelectorAll("button.grant-reroll").forEach(el => { el.addEventListener('click', async (ev) => { const btn = ev.currentTarget; const grantedRerollElement = html.querySelector('#granted-reroll'); const allowedRerollElement = html.querySelector("#allowed_reroll"); if (grantedRerollElement && allowedRerollElement) { allowedRerollElement.innerText = grantedRerollElement.innerText; } const mesEl = btn.closest('[data-message-id]'); const messageId = mesEl?.dataset?.messageId; if (messageId) { btn.closest('.reroll-from-effort').style.display = "none"; const rollMessage = btn.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 = game.users?.filter(user => user.isGM) || []; break; case "roll": // Everybody whisper = game.users?.filter(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) { // Aggregate dice stats per type (regular / human / adapted) const diceStats = { regular: { count: 0, success: 0 }, human: { count: 0, success: 0 }, adapted: { count: 0, success: 0 } }; for (const die of roll.dice ?? []) { // Flavors look like "_"; classify from the leading token. const flavor = die.options?.flavor ?? ""; const prefix = flavor.split("_")[0]; const type = prefix === "human" ? "human" : prefix === "adapted" ? "adapted" : "regular"; for (const r of die.results ?? []) { diceStats[type].count += 1; if (r.success) diceStats[type].success += 1; } } // Verdict const required = 1 + (param.handicap ?? 0); const bonusSuccesses = param.bonusSuccesses ?? 0; const total = (roll._total ?? 0) + bonusSuccesses; const verdict = { success: total >= required, total, required, bonusSuccesses }; const content = await foundry.applications.handlebars.renderTemplate("systems/vermine2047/templates/roll-message.hbs", { roll, param, diceStats, verdict }); const chatData = { user: game.user?._id, speaker: ChatMessage.getSpeaker(), content: content }; if (param.messageFlags) { chatData.flags = { world: param.messageFlags }; } const msg = await ChatMessage.create(chatData); return msg; } }