489 lines
15 KiB
JavaScript
489 lines
15 KiB
JavaScript
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
|
|
}) {
|
|
// 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<boolean>} 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<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 = 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<ChatMessage>} 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;
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|