Files
vermine2047/module/system/roll.mjs
T
uberwald b0a3aff648
Release Creation / build (release) Failing after 1m32s
feat: gestion des Dés de Totems (influence, instincts, interdits)
- VermineTotemDice : influence Humain/Adapté (+1D/-1D par domaine, règles
  p. 121), gains/pertes via Instincts/Interdits avec limites (3D/totem,
  5D total) et règles d'échange.
- roll.mjs applique l'influence selon le Totem dominant (au lieu de
  l'ancienne logique basée sur identity.totem).
- Dialogue TotemDiceDialog (instinct Humain/autre totem, interdit Humain,
  acte grave ±2D) + bouton et ligne d'influence sur la fiche personnage.
- Dés de Totem gagnés en cas d'échec à l'apprentissage Dé d'Évolution
  (dés dépensés, garde capacité vide).
- Couleur de texte @color-text-light-2 assombrie (#c9e0c0 → #3f9b55)
  pour la lisibilité dans tous les dialogues.

fix: robustesse et visibilité des évolutions

- buyEvolutionDialog : n'efface les Dés d'Évolution que si l'item est créé.
- Flag mutation éditable sur la fiche item evolution + badge
  Adaptation/Mutation dans la liste du personnage.
- loseDie : applique aussi la limite totale de 5 dés au gain Adapté.
- Case "acte grave" conservée entre les rendus du dialogue.
2026-08-06 15:32:55 +02:00

475 lines
16 KiB
JavaScript

import { VermineTotemDice } from "./totem-dice.mjs";
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<Roll>} 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;
// Influence des Dés de Totems : Bonus/Malus de 1D selon le Totem dominant
// et le Domaine de la Compétence (règles p. 121). S'applique même sans
// utiliser un Dé de Totem.
const influenceMod = this._calculateTotemInfluence(skillCategory, actor);
NoD += influenceMod;
// 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)`;
modFormula = humanFormula;
}
// Handle adapted totem
if (totems.adapted) {
NoD--;
const adaptedDifficulty = adjustedDifficulty;
const adaptedFormula = `(1D10cs>=${adaptedDifficulty}[adapted_${safeUserName}]*2)`;
// 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.
// L'influence s'applique au pool ; les Dés de Totem remplacent (n'ajoutent
// pas) des dés du pool de base.
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;
}
poolBreakdown.totemDice = totemDice;
poolBreakdown.domainBonus = influenceMod;
// Total = nombre réel de dés lancés (NoD réguliers + dés de totem).
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,
totemInfluence: influenceMod,
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 {number} Modificateur d'influence (-1, 0 ou +1)
*/
static _calculateTotemInfluence(skillCategory, actor) {
// Les Dés de Totems n'existent que sur les personnages.
if (actor?.type !== "character" || !skillCategory) return 0;
return VermineTotemDice.influenceModifier(actor, skillCategory);
}
/**
* 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<boolean>} 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 ? '<i class="fas fa-check-circle"></i>' : '<i class="fas fa-times-circle"></i>'}<span>${label}</span>`;
}
// 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 ? `<span class="die-summary ${key}">${count} ${game.i18n.localize(`VERMINE.die_${key}`)} · ${s} ${game.i18n.localize('VERMINE.die_successes')}</span>` : '';
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<boolean>} 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<ChatMessage>} 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 "<type>_<user>"; 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;
}
}