Files
fvtt-donjon-et-cie/modules/donjon-et-cie-rolls.mjs

559 lines
22 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Donjon & Cie - Systeme FoundryVTT
*
* Donjon & Cie est un jeu de role edite par John Doe.
* Ce systeme FoundryVTT est une implementation independante et n'est pas
* affilie a John Doe.
*
* @author LeRatierBretonnien
* @copyright 20252026 LeRatierBretonnien
* @license CC BY-NC-SA 4.0 https://creativecommons.org/licenses/by-nc-sa/4.0/
*/
import { DonjonEtCieUtility } from "./donjon-et-cie-utility.mjs";
import { DONJON_ET_CIE } from "./donjon-et-cie-config.mjs";
export class DonjonEtCieRolls {
static async #createChatCard(actor, template, context) {
const content = await foundry.applications.handlebars.renderTemplate(template, context);
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
user: game.user.id,
content
});
}
static #selectKeptValue(values, mode, favorable = "low") {
if (!values.length) return null;
if (mode === "normal") return values[0];
const selector = favorable === "low"
? (mode === "avantage" ? Math.min : Math.max)
: (mode === "avantage" ? Math.max : Math.min);
return selector(...values);
}
static #getModeLabel(mode) {
if (mode === "avantage") return "Avantage";
if (mode === "desavantage") return "Desavantage";
return null;
}
static #applyFavorMode(mode) {
if (mode === "desavantage") return "normal";
return "avantage";
}
static async #resolveFormulaRoll(formula, data = {}, { mode = "normal", favorable = "high" } = {}) {
const rollCount = mode === "normal" ? 1 : 2;
const rolls = await Promise.all(Array.from({ length: rollCount }, () => (new Roll(formula, data)).evaluate()));
const values = rolls.map((roll) => roll.total);
const kept = this.#selectKeptValue(values, mode, favorable);
const keptIndex = Math.max(0, values.findIndex((value) => value === kept));
const keptRoll = rolls[keptIndex] ?? rolls[0];
return { rolls, values, kept, keptIndex, keptRoll, mode, formula: keptRoll.formula };
}
static async #resolveCharacteristic(actor, characteristicKey, { mode = "normal" } = {}) {
const characteristic = actor.system.caracteristiques?.[characteristicKey];
if (!characteristic) return null;
const target = Number(characteristic.value ?? 0);
const rollCount = mode === "normal" ? 1 : 2;
const roll = await (new Roll(`${rollCount}d20`)).evaluate();
const values = roll.dice[0]?.results?.map((result) => result.result) ?? [];
const kept = this.#selectKeptValue(values, mode, "low");
const success = kept <= target;
return { characteristic, characteristicKey, target, values, kept, success, mode, isNaturalOne: kept === 1, isNaturalTwenty: kept === 20 };
}
static async #resolveFavorBoost(actor, favorKey, mode = "normal") {
if (!favorKey) return null;
const label = DonjonEtCieUtility.getFavorLabel(favorKey);
const path = `system.faveurs.${favorKey}.delta`;
const before = Number(foundry.utils.getProperty(actor, path) ?? 0);
if (!before) {
ui.notifications.warn(`Aucune faveur disponible pour ${label}.`);
return null;
}
const resolved = await this.#resolveFormulaRoll(`1d${before}`, {}, { favorable: "high" });
const result = resolved.kept;
const degraded = result <= 3;
const after = degraded ? DonjonEtCieUtility.degradeUsageDie(before) : before;
if (after !== before) {
await actor.update({ [path]: after });
}
return {
key: favorKey,
label,
before,
after,
result,
degraded,
stable: !degraded,
effectiveMode: this.#applyFavorMode(mode),
modeBefore: mode,
modeAfter: this.#applyFavorMode(mode),
note: degraded
? "Le coup de pouce reste anonyme : un collegue du departement a donne l'info utile."
: "Le coup de pouce tient bon : nommez le collegue, ses trois traits et la relation pour le trombinoscope."
};
}
static async useFavorService(actor, favorKey) {
if (!favorKey) return null;
const label = DonjonEtCieUtility.getFavorLabel(favorKey);
const path = `system.faveurs.${favorKey}.delta`;
const before = Number(foundry.utils.getProperty(actor, path) ?? 0);
if (!before) {
ui.notifications.warn(`Aucune faveur disponible pour ${label}.`);
return null;
}
const after = DonjonEtCieUtility.degradeUsageDie(before);
await actor.update({ [path]: after });
await this.#createChatCard(actor, "systems/fvtt-donjon-et-cie/templates/chat/favor-card.hbs", {
title: game.i18n.localize("DNC.Roll.Favor"),
subtitle: label,
kindLabel: "Service",
before: DonjonEtCieUtility.formatUsageDie(before),
after: DonjonEtCieUtility.formatUsageDie(after),
autoSpent: true,
note: "La faveur est brulee pour obtenir directement l'aide souhaitee, a la discretion du MJ."
});
return { key: favorKey, label, before, after };
}
static async #ensureFocus(actor) {
const focusDelta = Number(actor.system.magie?.focus?.delta ?? 0);
const focusResult = Number(actor.system.magie?.focus?.resultat ?? 0);
const focusSceneId = actor.system.magie?.focus?.sceneId ?? "";
const currentSceneId = DonjonEtCieUtility.getCurrentSceneId();
const sameScene = focusSceneId === currentSceneId;
const activeFocus = sameScene ? focusResult : 0;
if (!focusDelta) {
return { delta: 0, activeValue: 0, rolled: false, before: 0, after: 0, degraded: false };
}
if (sameScene) {
return { delta: focusDelta, activeValue: activeFocus, rolled: false, before: focusDelta, after: focusDelta, degraded: false };
}
const resolved = await this.#resolveFormulaRoll(`1d${focusDelta}`, {}, { favorable: "high" });
const result = resolved.kept;
const degraded = result <= 3;
const after = degraded ? DonjonEtCieUtility.degradeUsageDie(focusDelta) : focusDelta;
const updateData = {
"system.magie.focus.resultat": result,
"system.magie.focus.sceneId": currentSceneId
};
if (after !== focusDelta) {
updateData["system.magie.focus.delta"] = after;
}
await actor.update(updateData);
return {
delta: after,
activeValue: result,
rolled: true,
before: focusDelta,
after,
degraded,
values: resolved.values
};
}
static async rollCharacteristic(actor, characteristicKey, { mode = "normal", label = null, favorKey = "" } = {}) {
const favor = await this.#resolveFavorBoost(actor, favorKey, mode);
const effectiveMode = favor?.effectiveMode ?? mode;
const result = await this.#resolveCharacteristic(actor, characteristicKey, { mode: effectiveMode });
if (!result) return null;
await this.#createChatCard(actor, "systems/fvtt-donjon-et-cie/templates/chat/roll-card.hbs", {
title: label ?? "Jet de caracteristique",
subtitle: result.characteristic.label,
formula: result.values.length > 1 ? "2d20" : "1d20",
mode: effectiveMode,
modeLabel: this.#getModeLabel(effectiveMode),
target: result.target,
targetPillLabel: "Cible",
targetPillValue: result.target,
values: result.values,
kept: result.kept,
keptPillLabel: "Garde",
keptPillValue: result.kept,
success: result.success,
favorLabel: favor?.label ?? null,
favorNote: favor?.note ?? null,
details: [
{ label: "Caracteristique", value: result.characteristic.label },
{ label: "Valeur cible", value: result.target },
...(favor ? [
{ label: "Faveur", value: favor.label },
{ label: "Dé de faveur", value: favor.result },
{ label: "Avant", value: DonjonEtCieUtility.formatUsageDie(favor.before) },
{ label: "Apres", value: DonjonEtCieUtility.formatUsageDie(favor.after) }
] : [])
]
});
return { ...result, favor, mode: effectiveMode };
}
static async rollInitiative(actor, { mode = "normal" } = {}) {
const dex = Number(actor.system.caracteristiques?.dexterite?.value ?? 0);
const sheetBonus = Number(actor.system.combat?.initiativeBonus ?? 0);
const result = await this.#resolveFormulaRoll("1d20 + @dex + @sheetBonus", { dex, sheetBonus }, { mode, favorable: "high" });
const dieValues = result.rolls.map((roll) => roll.dice[0]?.results?.[0]?.result ?? roll.total);
const die = dieValues[result.keptIndex] ?? dieValues[0] ?? result.kept;
let syncedCombat = null;
const activeCombat = game.combats?.contents?.find((combat) => combat.active);
const combatant = activeCombat?.combatants?.find((entry) => entry.actorId === actor.id);
if (combatant) {
await activeCombat.setInitiative(combatant.id, result.kept);
const ordered = [...activeCombat.combatants].sort((a, b) => (b.initiative ?? -Infinity) - (a.initiative ?? -Infinity));
syncedCombat = {
name: activeCombat.name,
initiative: result.kept,
rank: ordered.findIndex((entry) => entry.id === combatant.id) + 1,
total: ordered.length
};
}
await this.#createChatCard(actor, "systems/fvtt-donjon-et-cie/templates/chat/initiative-card.hbs", {
title: game.i18n.localize("DNC.Roll.Initiative"),
actorName: actor.name,
total: result.kept,
formula: result.rolls.length > 1 ? `2 × ${result.formula}` : result.formula,
die,
dieValues,
dex,
bonus: sheetBonus,
mode: result.mode,
modeLabel: this.#getModeLabel(result.mode),
syncedCombat
});
return { total: result.kept, die, dieValues, dex, bonus: sheetBonus, mode: result.mode, syncedCombat };
}
static async rollHitDice(actor) {
const formula = String(actor.system.sante?.dv ?? "").trim();
if (!formula) return null;
let roll;
try {
roll = await (new Roll(formula)).evaluate();
} catch (error) {
ui.notifications.error(`Formule de DV invalide : ${formula}`);
throw error;
}
const dieValues = roll.dice.flatMap((die) => die.results?.map((result) => result.result) ?? []);
await this.#createChatCard(actor, "systems/fvtt-donjon-et-cie/templates/chat/hit-dice-card.hbs", {
title: game.i18n.localize("DNC.Roll.HitDice"),
actorName: actor.name,
formula: roll.formula,
total: roll.total,
dieValues
});
return { formula: roll.formula, total: roll.total, dieValues };
}
static async rollWeapon(actor, item, { mode = "normal", favorKey = "" } = {}) {
const characteristicKey = DonjonEtCieUtility.getWeaponCharacteristicKey(item.system.categorie);
const favor = await this.#resolveFavorBoost(actor, favorKey, mode);
const effectiveMode = favor?.effectiveMode ?? mode;
const result = await this.#resolveCharacteristic(actor, characteristicKey, { mode: effectiveMode });
if (!result) return null;
const characteristicLabel = DONJON_ET_CIE.characteristics[characteristicKey]?.label ?? characteristicKey;
const characteristicShort = DONJON_ET_CIE.characteristics[characteristicKey]?.short ?? characteristicKey;
await this.#createChatCard(actor, "systems/fvtt-donjon-et-cie/templates/chat/roll-card.hbs", {
title: `${game.i18n.localize("DNC.Roll.Attack")} : ${item.name}`,
subtitle: DONJON_ET_CIE.weaponCategoryOptions[item.system.categorie] ?? item.system.categorie,
formula: result.values.length > 1 ? "2d20" : "1d20",
mode: effectiveMode,
modeLabel: this.#getModeLabel(effectiveMode),
target: result.target,
targetPillLabel: characteristicShort,
targetPillValue: result.target,
values: result.values,
kept: result.kept,
keptPillLabel: "Jet",
keptPillValue: result.kept,
success: result.success,
favorLabel: favor?.label ?? null,
favorNote: favor?.note ?? null,
showDamageButton: result.success && Boolean(item.system.degats),
itemUuid: item.uuid,
details: [
{ label: "Arme", value: item.name },
{ label: "Caracteristique", value: characteristicLabel },
{ label: `Valeur de ${characteristicLabel}`, value: result.target },
{ label: "Degats", value: item.system.degats || "—" },
{ label: "Portee", value: item.system.portee || "—" },
...(favor ? [
{ label: "Faveur", value: favor.label },
{ label: "Dé de faveur", value: favor.result },
{ label: "Avant", value: DonjonEtCieUtility.formatUsageDie(favor.before) },
{ label: "Apres", value: DonjonEtCieUtility.formatUsageDie(favor.after) }
] : [])
]
});
return { ...result, favor, mode: effectiveMode };
}
static async rollDamage(actor, item, { mode = "normal" } = {}) {
if (!item.system.degats) return null;
const actorBonus = Number(actor?.system?.combat?.degatsBonus ?? 0);
const totalBonus = actorBonus;
const formula = totalBonus ? `${item.system.degats} + ${totalBonus}` : item.system.degats;
const result = await this.#resolveFormulaRoll(formula, {}, { mode, favorable: "high" });
const targets = DonjonEtCieUtility.getSceneDamageTargets();
const rollDieLabels = result.rolls.map((roll) => {
const dieValues = roll.dice.flatMap((die) => die.results?.map((dieResult) => dieResult.result) ?? []);
return dieValues.length ? dieValues.join(" + ") : String(roll.total ?? "—");
});
const keptDieLabel = rollDieLabels[result.keptIndex] ?? rollDieLabels[0] ?? String(result.kept);
await this.#createChatCard(actor ?? item.actor, "systems/fvtt-donjon-et-cie/templates/chat/damage-card.hbs", {
title: `${game.i18n.localize("DNC.Roll.Damage")} : ${item.name}`,
subtitle: item.system.portee || item.type,
formula: result.rolls.length > 1 ? `2 × ${result.formula}` : result.formula,
mode: result.mode,
modeLabel: this.#getModeLabel(result.mode),
rollDieLabels,
keptDieLabel,
values: result.values,
total: result.kept,
bonus: totalBonus,
baseDamage: item.system.degats,
sourceLabel: item.name,
targets,
hasTargets: targets.length > 0
});
return { total: result.kept, formula: result.formula, bonus: totalBonus, values: result.values, mode: result.mode };
}
static async applyDamage(target, { damage = 0, useArmor = false, sourceLabel = "" } = {}) {
const actor = target?.actor ?? target;
if (!actor || actor.documentName !== "Actor") {
ui.notifications.warn(game.i18n.localize("DNC.Chat.InvalidDamageTarget"));
return null;
}
const targetName = target?.name ?? actor.name;
const applied = await actor.applyIncomingDamage(damage, { useArmor });
await this.#createChatCard(actor, "systems/fvtt-donjon-et-cie/templates/chat/damage-application-card.hbs", {
title: game.i18n.localize("DNC.Chat.DamageApplied"),
subtitle: targetName,
sourceLabel,
total: applied.hpDamage,
incoming: applied.incoming,
useArmor: applied.useArmor,
armorLabel: applied.armorLabel,
armorAvailable: applied.armorAvailable,
armorBefore: applied.armorBefore,
armorAbsorbed: applied.armorAbsorbed,
armorAfter: applied.armorAfter,
pvBefore: applied.pvBefore,
pvAfter: applied.pvAfter,
pvMax: applied.pvMax
});
return { actor, targetName, ...applied };
}
static async rollSpell(actor, item, { mode = "normal", favorKey = "" } = {}) {
const characteristicKey = item.system.caracteristique || "intelligence";
const focus = await this.#ensureFocus(actor);
const rank = Number(actor.system.anciennete?.rang ?? actor.system.sante?.dv ?? 0);
const cost = Number(item.system.coutPv ?? 0);
const autoDisadvantage = cost > rank;
const baseMode = autoDisadvantage ? "desavantage" : mode;
const favor = await this.#resolveFavorBoost(actor, favorKey, baseMode);
const effectiveMode = favor?.effectiveMode ?? baseMode;
const result = await this.#resolveCharacteristic(actor, characteristicKey, { mode: effectiveMode });
if (!result) return null;
const currentPv = Number(actor.system.sante?.pv?.value ?? 0);
const availableMagicHp = currentPv + focus.activeValue;
if (cost > availableMagicHp) {
ui.notifications.warn("Le lanceur ne dispose pas d'assez de PV et de focus pour payer ce sort.");
return null;
}
const characteristicShort = DONJON_ET_CIE.characteristics[characteristicKey]?.short ?? characteristicKey;
const success = result.isNaturalTwenty ? false : result.success;
const focusSpent = result.isNaturalOne ? 0 : Math.min(cost, focus.activeValue);
const focusRemaining = Math.max(focus.activeValue - focusSpent, 0);
const spentPv = result.isNaturalOne ? 0 : Math.max(cost - focusSpent, 0);
const remainingPv = Math.max(currentPv - spentPv, 0);
const updateData = {};
if (spentPv !== 0) {
updateData["system.sante.pv.value"] = remainingPv;
}
if (focusSpent !== 0) {
updateData["system.magie.focus.resultat"] = focusRemaining;
}
if (Object.keys(updateData).length) {
await actor.update(updateData);
}
const canInvokeChaos = !success && !result.isNaturalTwenty && Number(actor.system.magie?.chaos?.delta ?? 12) >= 4;
const specialNote = result.isNaturalTwenty
? "20 naturel : la magie tourne a la catastrophe, au choix du MJ."
: (result.isNaturalOne ? "1 naturel : effet benefique possible ; par defaut, aucun PV n'est depense." : null);
await this.#createChatCard(actor, "systems/fvtt-donjon-et-cie/templates/chat/spell-card.hbs", {
title: `${game.i18n.localize("DNC.Roll.Spell")} : ${item.name}`,
subtitle: item.system.portee || "Sortilege",
formula: result.values.length > 1 ? "2d20" : "1d20",
mode: effectiveMode,
modeLabel: this.#getModeLabel(effectiveMode),
autoDisadvantage,
autoDisadvantageCanceled: autoDisadvantage && Boolean(favor),
favorLabel: favor?.label ?? null,
favorNote: favor?.note ?? null,
targetPillLabel: characteristicShort,
targetPillValue: result.target,
values: result.values,
kept: result.kept,
keptPillLabel: "Jet",
keptPillValue: result.kept,
success,
specialNote,
showDamageButton: success && Boolean(item.system.degats),
showChaosButton: canInvokeChaos,
itemUuid: item.uuid,
actorUuid: actor.uuid,
details: [
{ label: "Sortilege", value: item.name },
{ label: "Caracteristique", value: result.characteristic.label },
{ label: "Valeur de la caracteristique", value: result.target },
{ label: "Cout en PV", value: cost },
{ label: "Focus", value: focus.activeValue > 0 ? `${focus.activeValue} (${DonjonEtCieUtility.formatUsageDie(focus.before)})` : "—" },
{ label: "Focus depense", value: focusSpent },
{ label: "Focus restant", value: focusRemaining },
{ label: "PV depenses", value: spentPv },
{ label: "PV restants", value: remainingPv },
{ label: "Rang du lanceur", value: rank },
{ label: "Difficulte", value: item.system.difficulte ?? 0 },
{ label: "Effet", value: item.system.effet || "—" },
...(favor ? [
{ label: "Faveur", value: favor.label },
{ label: "Dé de faveur", value: favor.result },
{ label: "Avant", value: DonjonEtCieUtility.formatUsageDie(favor.before) },
{ label: "Apres", value: DonjonEtCieUtility.formatUsageDie(favor.after) }
] : [])
],
focusRolled: focus.rolled,
focusValue: focus.activeValue,
focusSpent,
focusRemaining,
focusBeforeLabel: DonjonEtCieUtility.formatUsageDie(focus.before),
focusAfterLabel: DonjonEtCieUtility.formatUsageDie(focus.after),
focusDegraded: focus.degraded,
spentPv,
remainingPv
});
return { ...result, success, spentPv, remainingPv, cost, focus, focusSpent, focusRemaining, favor, mode: effectiveMode };
}
static async rollSpellChaos(actor, item) {
const before = Number(actor?.system?.magie?.chaos?.delta ?? 12);
if (!before || before < 4) {
ui.notifications.warn("Le Chaos n'est pas disponible pour ce sort.");
return null;
}
const resolved = await this.#resolveFormulaRoll(`1d${before}`, {}, { favorable: "high" });
const result = resolved.kept;
const degraded = result <= 3;
const after = degraded ? DonjonEtCieUtility.degradeUsageDie(before) : before;
const chaosEntry = DONJON_ET_CIE.chaosTable[result] ?? null;
if (after !== before) {
await actor.update({ "system.magie.chaos.delta": after });
}
await this.#createChatCard(actor, "systems/fvtt-donjon-et-cie/templates/chat/chaos-card.hbs", {
title: `Chaos : ${item.name}`,
value: result,
before: DonjonEtCieUtility.formatUsageDie(before),
after: DonjonEtCieUtility.formatUsageDie(after),
chaosEntry,
degraded,
exhausted: after < 4,
itemName: item.name
});
return { result, before, after, degraded, chaosEntry };
}
static async rollUsage(item, { mode = "normal" } = {}) {
const before = Number(item.system.delta ?? 0);
if (!before) return null;
const resolved = await this.#resolveFormulaRoll(`1d${before}`, {}, { mode, favorable: "high" });
const result = resolved.kept;
const degraded = result <= 3;
const after = degraded ? DonjonEtCieUtility.degradeUsageDie(before) : before;
const updateData = {};
if (item.type === "armure") {
updateData["system.resultatProtection"] = result;
}
if (after !== before) {
updateData["system.delta"] = after;
}
if (Object.keys(updateData).length) {
await item.update(updateData);
}
await this.#createChatCard(item.actor, "systems/fvtt-donjon-et-cie/templates/chat/usage-card.hbs", {
title: `${game.i18n.localize("DNC.Roll.Usage")} : ${item.name}`,
value: result,
values: resolved.values,
mode: resolved.mode,
modeLabel: this.#getModeLabel(resolved.mode),
before: DonjonEtCieUtility.formatUsageDie(before),
after: DonjonEtCieUtility.formatUsageDie(after),
protectionStored: item.type === "armure" ? result : null,
degraded,
exhausted: after === 0
});
return { result, values: resolved.values, mode: resolved.mode, before, after, degraded };
}
}