feat: améliorer les fiches (tests PNJ/créature, message de jet, compétences, créatures)
Release Creation / build (release) Failing after 1m23s

This commit is contained in:
2026-08-04 00:49:37 +02:00
parent 9c7ebc6df9
commit 31f2f4530c
28 changed files with 736 additions and 224 deletions
@@ -51,6 +51,7 @@ export default class VermineCharacterSheetV2 extends VermineBaseActorSheet {
case "main": break
case "abilities":
context.tab = context.tabs.abilities
context.specialties = doc.itemTypes.specialty
break
case "totem":
context.tab = context.tabs.totem
+40 -3
View File
@@ -2,6 +2,10 @@ import VermineBaseActorSheet from "./base-actor-sheet.mjs"
export default class VermineCreatureSheetV2 extends VermineBaseActorSheet {
// La fiche créature s'ouvre en mode édition : Gabarit, Taille, Rôle et Meute
// se règlent dès l'ouverture (le mode jeu reste accessible via le toggle).
_sheetMode = this.constructor.SHEET_MODES.EDIT
static DEFAULT_OPTIONS = {
classes: ["creature"],
position: { width: 700, height: 650 },
@@ -61,10 +65,22 @@ export default class VermineCreatureSheetV2 extends VermineBaseActorSheet {
break
case "stats":
context.tab = context.tabs.stats
context.patternLabel = doc.system.pattern?.value ? CONFIG.VERMINE.creaturePatternLevels[doc.system.pattern.value]?.label : ""
context.sizeLabel = doc.system.size?.value || ""
context.roleLabel = doc.system.role?.value ? CONFIG.VERMINE.creatureRoleLevels[doc.system.role.value]?.label : ""
context.patternLabel = doc.system.pattern?.value ? game.i18n.localize(CONFIG.VERMINE.creaturePatternLevels[doc.system.pattern.value]?.label ?? "") : ""
context.sizeLabel = doc.system.size?.value !== undefined ? game.i18n.localize(CONFIG.VERMINE.creatureSizeLevels[doc.system.size.value]?.label ?? "") : ""
context.roleLabel = doc.system.role?.value ? game.i18n.localize(CONFIG.VERMINE.creatureRoleLevels[doc.system.role.value]?.label ?? "") : ""
context.packLabel = doc.system.pack?.value || game.i18n.localize("VERMINE.none")
// Contribution cumulée de la Taille (les modificateurs se cumulent : Taille N = somme 1..N)
const sizeLevels = CONFIG.VERMINE.creatureSizeLevels || {}
const sizeLevel = doc.system.size?.value ?? 0
context.sizeCumulative = { attack: 0, vigor: 0, minorWound: 0, majorWound: 0, deadlyWound: 0 }
for (let i = 1; i <= sizeLevel; i++) {
const cfg = sizeLevels[i] || {}
context.sizeCumulative.attack += cfg.attack || 0
context.sizeCumulative.vigor += cfg.vigor || 0
context.sizeCumulative.minorWound += cfg.minorWound || 0
context.sizeCumulative.majorWound += cfg.majorWound || 0
context.sizeCumulative.deadlyWound += cfg.deadlyWound || 0
}
break
case "combat":
context.tab = context.tabs.combat
@@ -77,4 +93,25 @@ export default class VermineCreatureSheetV2 extends VermineBaseActorSheet {
}
return context
}
/** @override - Les créatures n'ont pas de caractéristiques/compétences : on lance directement le pool d'attaque. */
async _onRoll(event) {
event.preventDefault()
const el = event.currentTarget
const type = el.dataset.type
if (type !== "creature-attack") return super._onRoll(event)
const pool = this.document.system.computed?.attack ?? 0
if (!pool) {
ui.notifications.warn(game.i18n.localize("VERMINE.error_select_ability"))
return
}
const difficulty = this.document.system.combatStatus?.difficulty ?? 7
const { VermineUtils } = await import("../../system/roll.mjs")
await VermineUtils.roll({
actor: this.document,
NoD: pool,
difficulty: parseInt(difficulty, 10),
rollLabel: game.i18n.localize("ADVERSITY.attack")
})
}
}
+4
View File
@@ -2,6 +2,10 @@ import VermineBaseActorSheet from "./base-actor-sheet.mjs"
export default class VermineNpcSheetV2 extends VermineBaseActorSheet {
// La fiche PNJ s'ouvre en mode édition : caractéristiques, compétences et
// niveaux se règlent dès l'ouverture (le mode jeu reste accessible via le toggle).
_sheetMode = this.constructor.SHEET_MODES.EDIT
static DEFAULT_OPTIONS = {
classes: ["npc"],
position: { width: 750, height: 680 },
+1 -1
View File
@@ -219,7 +219,7 @@ const reqInt = { required: true, nullable: false, integer: true }
export function raritySchema() {
const fields = foundry.data.fields
return new fields.SchemaField({
value: new fields.NumberField({ ...reqInt, initial: 3, min: 1, max: 5 }),
value: new fields.NumberField({ ...reqInt, initial: 3, min: 1, max: 10 }),
handicap: new fields.NumberField({ ...reqInt, initial: 0, min: 0 })
})
}
+32 -11
View File
@@ -48,7 +48,7 @@ export default class VermineCreatureData extends foundry.abstract.TypeDataModel
// Niveaux de créature (patron, taille, rôle, meute)
pattern: attributeSchema(1, 1, 4),
size: attributeSchema(1, 1, 3),
size: attributeSchema(0, 0, 3),
role: attributeSchema(1, 1, 4),
pack: attributeSchema(0, 0, 3),
@@ -97,28 +97,38 @@ export default class VermineCreatureData extends foundry.abstract.TypeDataModel
*/
_calculateCreatureComputedValues() {
const patternLevel = this.pattern?.value || 1
const sizeLevel = this.size?.value || 1
const sizeLevel = this.size?.value || 0
const roleLevel = this.role?.value || 1
const packLevel = this.pack?.value || 0
const patternConfig = CONFIG.VERMINE.creaturePatternLevels[patternLevel] || {}
const sizeConfig = CONFIG.VERMINE.creatureSizeLevels[sizeLevel] || {}
const roleConfig = CONFIG.VERMINE.creatureRoleLevels[roleLevel] || {}
const packConfig = CONFIG.VERMINE.creaturePackLevels[packLevel] || {}
// Les modificateurs de Taille se cumulent : une créature de Taille N applique
// les modificateurs de chaque Taille de 1 à N (Taille 0 = insecte, aucun).
const sizeLevels = CONFIG.VERMINE.creatureSizeLevels || {}
let sizeAttack = 0
let sizeVigor = 0
for (let i = 1; i <= sizeLevel; i++) {
const cfg = sizeLevels[i] || {}
sizeAttack += cfg.attack || 0
sizeVigor += cfg.vigor || 0
}
// Attaque : patron + taille + meute + réaction du rôle
this.computed.attack = (patternConfig.attack || 0)
+ (sizeConfig.attack || 0)
+ sizeAttack
+ (packConfig.attack || 0)
+ (roleConfig.reaction || 0)
// Dégâts : patron + vigueur de taille + meute
this.computed.damage = (patternConfig.damage || 0)
+ (sizeConfig.vigor || 0)
+ sizeVigor
+ (packConfig.damage || 0)
// Vigueur : taille + meute
this.computed.vigor = (sizeConfig.vigor || 0) + (packConfig.damage || 0)
this.computed.vigor = sizeVigor + (packConfig.damage || 0)
// Réaction : rôle
this.computed.reaction = (roleConfig.reaction || 0) + (roleConfig.reaction_bonus || 0)
@@ -141,21 +151,32 @@ export default class VermineCreatureData extends foundry.abstract.TypeDataModel
*/
_calculateCreatureWoundThresholds() {
const patternLevel = this.pattern?.value || 1
const sizeLevel = this.size?.value || 1
const sizeLevel = this.size?.value || 0
const packLevel = this.pack?.value || 0
const patternConfig = CONFIG.VERMINE.creaturePatternLevels[patternLevel] || {}
const sizeConfig = CONFIG.VERMINE.creatureSizeLevels[sizeLevel] || {}
const packConfig = CONFIG.VERMINE.creaturePackLevels[packLevel] || {}
// Les modificateurs de Taille se cumulent (Taille N = somme des tailles 1..N).
const sizeLevels = CONFIG.VERMINE.creatureSizeLevels || {}
let sizeMinor = 0
let sizeMajor = 0
let sizeDeadly = 0
for (let i = 1; i <= sizeLevel; i++) {
const cfg = sizeLevels[i] || {}
sizeMinor += cfg.minorWound || 0
sizeMajor += cfg.majorWound || 0
sizeDeadly += cfg.deadlyWound || 0
}
this.minorWound.threshold = (patternConfig.minorWound || 0)
+ (sizeConfig.minorWound || 0)
+ sizeMinor
+ (packConfig.minorWound || 0)
this.majorWound.threshold = (patternConfig.majorWound || 0)
+ (sizeConfig.majorWound || 0)
+ sizeMajor
+ (packConfig.majorWound || 0)
this.deadlyWound.threshold = (patternConfig.deadlyWound || 0)
+ (sizeConfig.deadlyWound || 0)
+ sizeDeadly
+ (packConfig.deadlyWound || 0)
// Max de blessures
+3 -1
View File
@@ -10,9 +10,11 @@ export default class VermineSpecialtyData extends foundry.abstract.TypeDataModel
/** @override */
static defineSchema() {
const fields = foundry.data.fields
const reqInt = { required: true, nullable: false, integer: true }
return {
name: new fields.StringField({ required: true, nullable: false, initial: "" }),
skill: new fields.StringField({ required: true, nullable: false, initial: "" })
skill: new fields.StringField({ required: true, nullable: false, initial: "" }),
level: new fields.NumberField({ ...reqInt, initial: 1, min: 0, max: 5 })
}
}
}
+4 -3
View File
@@ -139,9 +139,10 @@ VERMINE.creaturePatternLevels = {
* Creature Size Levels configuration
*/
VERMINE.creatureSizeLevels = {
1: { "label": "SIZE_LEVELS.small", "attack": 2, "vigor": 1, "minorWound": 0, "majorWound": 0, "deadlyWound": 1 },
2: { "label": "SIZE_LEVELS.medium", "attack": 3, "vigor": 2, "minorWound": 0, "majorWound": 1, "deadlyWound": 1 },
3: { "label": "SIZE_LEVELS.large", "attack": 4, "vigor": 3, "minorWound": 1, "majorWound": 1, "deadlyWound": 1 }
0: { "label": "SIZE_LEVELS.tiny", "attack": 0, "vigor": 0, "minorWound": 0, "majorWound": 0, "deadlyWound": 0 },
1: { "label": "SIZE_LEVELS.small", "attack": 1, "vigor": 1, "minorWound": 0, "majorWound": 1, "deadlyWound": 1 },
2: { "label": "SIZE_LEVELS.medium", "attack": 2, "vigor": 2, "minorWound": 2, "majorWound": 2, "deadlyWound": 2 },
3: { "label": "SIZE_LEVELS.large", "attack": 3, "vigor": 3, "minorWound": 3, "majorWound": 3, "deadlyWound": 3 }
}
/**
+26 -16
View File
@@ -112,18 +112,32 @@ export default class RollDialog extends HandlebarsApplicationMixin(foundry.appli
#getHandicap() { return this.#el.querySelector("#handicap"); }
#getSelfCtrl() { return this.#el.querySelector("#self_control"); }
getDicePool() {
/** Décomposition du pool de dés pour affichage dans le message. */
getPoolBreakdown() {
const abil = this.#getAbility();
const abilVal = parseInt(abil?.options[abil?.selectedIndex]?.value, 10) || 0;
const ability = parseInt(abil?.options[abil?.selectedIndex]?.value, 10) || 0;
const skill = this.#getSkill();
const skillPool = parseInt(skill?.options[skill?.selectedIndex]?.dataset?.pool, 10) || 0;
const sc = parseInt(this.#getSelfCtrl()?.value, 10) || 0;
const specChecked = this.#el.querySelector("#usingSpecialization")?.checked;
const selfControl = parseInt(this.#getSelfCtrl()?.value, 10) || 0;
const specChecked = this.hasSpecialtySelected();
const helped = this.#el.querySelector("#helped")?.checked;
const tools = this.#el.querySelector("input[name='usingTools']:checked")?.value !== "0";
const toolsChecked = this.#el.querySelector("input[name='usingTools']:checked");
const tools = toolsChecked && toolsChecked.value !== "0";
const group = parseInt(this.#el.querySelector("#group")?.value, 10) || 0;
const bonuses = (specChecked ? 1 : 0) + (helped ? 1 : 0) + (tools ? 1 : 0) + group;
return (abilVal + sc + skillPool + bonuses) || 0;
const specialty = specChecked ? 1 : 0;
const help = helped ? 1 : 0;
const tooling = tools ? 1 : 0;
const total = ability + skillPool + selfControl + specialty + help + tooling + group;
return { ability, skill: skillPool, selfControl, specialty, help, tooling, group, total };
}
getDicePool() {
return this.getPoolBreakdown().total;
}
getSpecialtyName() {
const checked = this.#el.querySelector("input[name='usingSpecialization']:checked");
return checked && checked.value !== "aucune" ? checked.value : null;
}
getDifficultySelect() {
@@ -207,14 +221,8 @@ export default class RollDialog extends HandlebarsApplicationMixin(foundry.appli
}
#calculateBonusCount() {
let b = 0;
if (this.#el.querySelector("#helped")?.checked) b += 1;
b += parseInt(this.#el.querySelector("#group")?.value, 10) || 0;
b += parseInt(this.#getSelfCtrl()?.value, 10) || 0;
const tools = this.#el.querySelector("input[name='usingTools']:checked");
if (tools && tools.value !== "0") b += 1;
if (this.hasSpecialtySelected()) b += 1;
return b;
const b = this.getPoolBreakdown();
return b.specialty + b.help + b.tooling + b.group + b.selfControl;
}
#updateUI() {
@@ -315,7 +323,9 @@ export default class RollDialog extends HandlebarsApplicationMixin(foundry.appli
keepTotem: this.getKeepTotem(),
skillCategory: this.getSkillCategory(),
skillLevel: this.getSkillLevel(),
hasSpecialty: this.hasSpecialtySelected()
hasSpecialty: this.hasSpecialtySelected(),
poolBreakdown: this.getPoolBreakdown(),
specialtyName: this.getSpecialtyName()
});
this.close();
+19
View File
@@ -32,6 +32,7 @@ export const preloadHandlebarsTemplates = async function () {
// npc partials
"systems/vermine2047/templates/actor/npc/npc-combat.hbs",
"systems/vermine2047/templates/actor/parts/npc-skill-category.hbs",
"systems/vermine2047/templates/actor/parts/npc-skill-item.hbs",
// creature partials
"systems/vermine2047/templates/actor/creature/creature-combat.hbs",
@@ -100,6 +101,21 @@ export const registerHandlebarsHelpers = function () {
return game.i18n.localize(arrayLabel + "." + objectLabel + ".name");
});
// Return an object of skills ordered alphabetically by their localized
// display name (easier to find a skill quickly). Use with `{{#each}}`.
Handlebars.registerHelper('sortedSkills', function (skills) {
if (!skills || typeof skills !== 'object') return skills;
const sorted = {};
Object.keys(skills)
.sort((a, b) => {
const na = game.i18n.localize(`SKILLS.${a}.name`).toLocaleLowerCase();
const nb = game.i18n.localize(`SKILLS.${b}.name`).toLocaleLowerCase();
return na.localeCompare(nb, game.i18n.lang);
})
.forEach(k => { sorted[k] = skills[k]; });
return sorted;
});
Handlebars.registerHelper('smarttlk', function (arrayLabel, objectLabel, key) {
return game.i18n.localize(arrayLabel + "." + objectLabel + "." + key);
});
@@ -347,6 +363,9 @@ export const registerHandlebarsHelpers = function () {
Handlebars.registerHelper('ifincludes', function (arg1, arg2, options) {
return (arg1.includes(arg2)) ? options.fn(this) : options.inverse(this);
});
Handlebars.registerHelper('ifStartsWith', function (arg1, arg2, options) {
return (typeof arg1 === 'string' && arg1.startsWith(arg2)) ? options.fn(this) : options.inverse(this);
});
//math operations
+114 -28
View File
@@ -29,7 +29,9 @@ export class VermineUtils {
keepTotem = null,
skillLevel = null,
hasSpecialty = false,
handicap = 0
handicap = 0,
poolBreakdown = null,
specialtyName = null
}) {
// Validate inputs
if (!actor) {
@@ -105,6 +107,27 @@ export class VermineUtils {
// 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());
@@ -143,7 +166,9 @@ export class VermineUtils {
keepTotem,
skillLevel,
hasSpecialty,
handicap
handicap,
poolBreakdown,
specialtyName
});
return roll;
@@ -199,10 +224,10 @@ export class VermineUtils {
/**
* Handles reroll events on dice in chat messages.
* @param {Object} message - The chat message containing the reroll event
* @param {Object} ev - The reroll event
* @param {HTMLElement} target - The clicked die element
* @returns {Promise<boolean>} Whether the reroll was successful
*/
static async onReroll(message, ev) {
static async onReroll(message, target) {
// Verify user permissions
const msgUserId = message.user?.id ?? message.user;
if (msgUserId !== game.user?.id && !game.user?.isGM) {
@@ -211,7 +236,7 @@ export class VermineUtils {
}
// Get reroll count
const rollMessage = ev.currentTarget.closest('div.vermine-roll-message');
const rollMessage = target.closest('div.vermine-roll-message');
if (!rollMessage) {
return false;
}
@@ -221,22 +246,22 @@ export class VermineUtils {
// 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');
const rerollables = target.closest('ul')?.querySelectorAll('.rerollable');
if (rerollables) {
rerollables.forEach(el => el.classList.remove('rerollable'));
}
return false;
}
ev.currentTarget.classList.add('rerolled');
target.classList.add('rerolled');
// Set reroll flag
await message.setFlag("world", "reroll", true);
// Get difficulty and dice type
const ulElement = ev.currentTarget.closest('ul');
const ulElement = target.closest('ul');
const difficulty = ulElement?.dataset.difficulty ?? 7;
let diceType = ev.currentTarget.dataset.diceType;
let diceType = target.dataset.diceType;
// Sanitize user name
const safeUserName = (game.user?.name ?? "user").replace(/[^a-zA-Z0-9_]/g, '_');
@@ -265,30 +290,68 @@ export class VermineUtils {
// Update die display
const result = reroll.dice[0]?.results[0]?.result ?? 0;
const dieSpan = ev.currentTarget.querySelector('span');
const dieSpan = target.querySelector('span');
if (dieSpan) {
dieSpan.innerText = result;
}
// Update the face used for the CSS background image.
target.dataset.result = result;
// Update total if successful
// 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;
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;
}
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;
});
totalEl.innerText = total;
}
// Update message content
ev.currentTarget.classList.remove("rerollable");
const messageContent = ev.currentTarget.closest('div.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
});
@@ -314,10 +377,11 @@ export class VermineUtils {
html.querySelectorAll('.rerollable').forEach(el => {
el.addEventListener('click', async (ev) => {
ev.preventDefault();
const msgId = ev.currentTarget.closest("li.message")?.dataset?.messageId;
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, ev);
await VermineUtils.onReroll(message, dieEl);
}
});
});
@@ -336,6 +400,7 @@ export class VermineUtils {
// 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");
@@ -343,13 +408,13 @@ export class VermineUtils {
allowedRerollElement.innerText = grantedRerollElement.innerText;
}
const mesEl = ev.currentTarget.closest('[data-message-id]');
const mesEl = btn.closest('[data-message-id]');
const messageId = mesEl?.dataset?.messageId;
if (messageId) {
ev.currentTarget.closest('.reroll-from-effort').style.display = "none";
btn.closest('.reroll-from-effort').style.display = "none";
const rollMessage = ev.currentTarget.closest(".vermine-roll-message");
const rollMessage = btn.closest(".vermine-roll-message");
if (rollMessage) {
const content = rollMessage.outerHTML;
const message = await game.messages.get(messageId);
@@ -401,7 +466,28 @@ export class VermineUtils {
* @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 });
// 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 total = roll._total ?? 0;
const verdict = {
success: total >= required,
total,
required
};
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(),