feat(traits): tâche 0 + phases 2-4 — automatisation complète des Traits

Tâche 0 :
- item.mjs : champ equipped sur items génériques + toggle fiche
- nouveau module/system/traits.mjs (helpers partagés : equippedItems, lourdMalus, mobilityHandicap, feticheItem…)

Phase 2 — Combat :
- Lourd : malus cumulés auto (Vigueur < n) dans le dialogue + pool
- Rapide : handicap (n) pré-rempli dans la défense (character + PNJ) + note carte
- Rafale : case tir en rafale (+2 difficulté), +n réussites si réussi (bonusOnSuccess)
- Portée : portée effective n × Vigueur affichée
- Maniable/Mobilité : note de rappel (dialogue + carte)
- Incapacitant : bouton « Jet de Santé (n) » sur la carte d'échange
- getLabel() : libellés caractéristiques/compétences localisés

Phase 3 — Jets d'action :
- Ponctuel : annule jusqu'à n Handicaps, consommé au jet (Traits supprimé à 0)
- Pratique : outil sélectionné +2D au lieu de +1D
- Intimidant : case « Brandit » → -1D Psychologie ou Relance 1D
- Malus : champ « Malus temporaires » manuel déduit du pool
- Durée : note « n tours » sur la carte de jet d'arme
- roll() : transmission effectiveRange/mobilityHandicap/ponctuelUsed/intimidant (corrige l'affichage Portée/Mobilité)

Phase 4 — Acteur :
- Fétiche : +1D Effort ET Sang-Froid (max dérivé, config corrigé), alerte 2+ fétiches, action « Perdre le fétiche » (-2D, max -1D persistant feticheLoss)
- Zone : note + bouton « Appliquer aux cibles de la zone » (#onApplyZone)

CSS : styles LESS pour les nouvelles classes (fetiche-block, exchange-reminder/zone, traits-reminder, tool-bonus/ponctuel, duree-note)
i18n : 29 clés fr/en
TRAITS_PLAN.md : plan complet cochée
This commit is contained in:
2026-08-07 15:30:41 +02:00
parent 8dd5d02994
commit ef01651b1d
21 changed files with 770 additions and 63 deletions
+103 -16
View File
@@ -1,5 +1,6 @@
import { VermineUtils } from "../roll.mjs";
import { VermineExchange } from "../exchange.mjs";
import { VermineTraits } from "../traits.mjs";
const { HandlebarsApplicationMixin } = foundry.applications.api;
@@ -65,6 +66,7 @@ export default class RollDialog extends HandlebarsApplicationMixin(foundry.appli
const isCharacter = actor.type === "character";
const group = isCharacter ? RollDialog.#findGroup(actor) : null;
const groupPool = group?.system?.experience?.dice || 0;
const intimidant = isCharacter ? VermineTraits.equippedItems(actor, 'intimidant')[0] ?? null : null;
return {
actor,
system: actor.system,
@@ -78,6 +80,8 @@ export default class RollDialog extends HandlebarsApplicationMixin(foundry.appli
specialty: false,
availableSpecialties: actor.items.filter(i => i.type === "specialty"),
availableItems: actor.items.filter(i => i.type === "item"),
hasIntimidant: Boolean(intimidant),
intimidantName: intimidant?.name ?? "",
locks: this.locks,
experience: {
hasExperience: isCharacter,
@@ -140,6 +144,50 @@ export default class RollDialog extends HandlebarsApplicationMixin(foundry.appli
#getSelfCtrl() { return this.#el.querySelector("#self_control"); }
#getExperiencePull() { return parseInt(this.#el.querySelector("#experience-pull")?.value, 10) || 0; }
/** Clé de la compétence sélectionnée (data-label) ou null. */
getSelectedSkillKey() {
const sel = this.#getSkill();
return sel?.options[sel.selectedIndex]?.dataset?.label ?? null;
}
/** Outil générique sélectionné (radio usingTools) ou null. */
getSelectedTool() {
const checked = this.#el.querySelector("input[name='usingTools']:checked");
if (!checked || checked.value === "0") return null;
return this.#actor.items.find(i => i.name === checked.value) ?? null;
}
/** Bonus d'outillage : +1D, ou +2D si l'outil possède le Trait Pratique. */
#getTooling() {
const tool = this.getSelectedTool();
if (!tool) return 0;
return VermineTraits.hasTrait(tool, 'pratique') ? 2 : 1;
}
/** Malus temporaires saisis manuellement (Trait Malus). */
#getTemporaryMalus() {
return parseInt(this.#el.querySelector("#temporary-malus")?.value, 10) || 0;
}
/** Case « Brandit » (Trait Intimidant). */
getIntimidant() {
return this.#el.querySelector("#intimidant")?.checked ?? false;
}
/** Valeur de Handicap brute du sélecteur (0-2). */
#getBaseHandicap() {
const sel = this.#getHandicap();
return Math.max(0, (parseInt(sel?.value, 10) || 1) - 1);
}
/** Nombre de Handicaps annulés par l'outil Ponctuel sélectionné. */
getPonctuelUsed() {
const tool = this.getSelectedTool();
const ponctuel = tool ? VermineTraits.traitValue(tool, 'ponctuel') : 0;
if (ponctuel <= 0) return 0;
return Math.min(ponctuel, this.#getBaseHandicap());
}
/**
* Trouve le premier Groupe dont le personnage est membre.
* @param {Actor} actor
@@ -158,16 +206,17 @@ export default class RollDialog extends HandlebarsApplicationMixin(foundry.appli
const selfControl = parseInt(this.#getSelfCtrl()?.value, 10) || 0;
const specChecked = this.hasSpecialtySelected();
const helped = this.#el.querySelector("#helped")?.checked;
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 experience = this.#getExperiencePull();
const specialty = specChecked ? 1 : 0;
const help = helped ? 1 : 0;
const tooling = tools ? 1 : 0;
const malus = VermineExchange.woundMalus(this.#actor);
const tooling = this.#getTooling();
const wound = VermineExchange.woundMalus(this.#actor);
const tempMalus = this.#getTemporaryMalus();
const psychology = this.getIntimidant() && this.getSelectedSkillKey() === 'psychology' ? 1 : 0;
const malus = wound + tempMalus + psychology;
const total = Math.max(0, ability + skillPool + selfControl + specialty + help + tooling + group + experience - malus);
return { ability, skill: skillPool, selfControl, specialty, help, tooling, group, experience, malus, total };
return { ability, skill: skillPool, selfControl, specialty, help, tooling, group, experience, wound, tempMalus, psychology, malus, total };
}
getDicePool() {
@@ -188,12 +237,15 @@ export default class RollDialog extends HandlebarsApplicationMixin(foundry.appli
getReroll() {
const sel = this.#getSkill();
const idx = sel?.selectedIndex ?? 0;
return parseInt(sel?.options[idx]?.dataset?.reroll, 10) || 0;
const base = parseInt(sel?.options[idx]?.dataset?.reroll, 10) || 0;
// Trait Intimidant (hors Psychologie) : Relance 1D tant que l'objet est brandi.
if (this.getIntimidant() && this.getSelectedSkillKey() !== 'psychology') return base + 1;
return base;
}
getHandicapSelect() {
const sel = this.#getHandicap();
return Math.max(0, (parseInt(sel?.value, 10) || 1) - 1);
// Trait Ponctuel : l'outil sélectionné annule jusqu'à (n) Handicaps.
return this.#getBaseHandicap() - this.getPonctuelUsed();
}
getSkillCategory() {
@@ -221,14 +273,13 @@ export default class RollDialog extends HandlebarsApplicationMixin(foundry.appli
getLabel() {
const type = this.getRollType();
if (type === "skill") {
const sel = this.#getSkill();
const idx = sel?.selectedIndex ?? 0;
return sel?.options[idx]?.dataset?.label ?? "";
}
const sel = this.#getAbility();
const sel = type === "skill" ? this.#getSkill() : this.#getAbility();
const idx = sel?.selectedIndex ?? 0;
return sel?.options[idx]?.dataset?.label ?? "";
const raw = sel?.options[idx]?.dataset?.label ?? "";
if (!raw) return "";
const base = type === "skill" ? "SKILLS" : "ABILITIES";
const localized = game.i18n.localize(`${base}.${raw}.name`);
return localized === `${base}.${raw}.name` ? raw : localized;
}
getSelfControl() {
@@ -285,6 +336,19 @@ export default class RollDialog extends HandlebarsApplicationMixin(foundry.appli
opt.selected = true;
difficulty.disabled = true;
}
const handicap = this.#getHandicap();
if (handicap && locks.handicap !== undefined && locks.handicap !== null) {
const locked = parseInt(locks.handicap, 10);
let opt = [...handicap.options].find(o => parseInt(o.value, 10) === locked + 1);
if (!opt) {
// Handicap supérieur aux options (ex: Rapide 3) : option dédiée.
opt = document.createElement("option");
opt.value = String(locked + 1);
opt.textContent = game.i18n.localize("VERMINE.handicap") + ` (${locked})`;
handicap.appendChild(opt);
}
opt.selected = true;
}
}
#displaySpecialties() {
@@ -342,7 +406,10 @@ export default class RollDialog extends HandlebarsApplicationMixin(foundry.appli
const handSel = this.#getHandicap();
const handEl = this.#el.querySelector("#current-handicap");
if (handEl && handSel) {
handEl.textContent = handSel.options[handSel.selectedIndex].text;
let text = handSel.options[handSel.selectedIndex].text;
const used = this.getPonctuelUsed();
if (used > 0) text += ` (${game.i18n.localize("VERMINE.ponctuel")} -${used})`;
handEl.textContent = text;
}
const abilSel = this.#getAbility();
@@ -450,6 +517,22 @@ export default class RollDialog extends HandlebarsApplicationMixin(foundry.appli
await group.update({ "system.experience.dice": groupPool - pulled });
}
// Trait Ponctuel : consomme (n) charges de l'outil sélectionné pour annuler
// les Handicaps ; l'objet perd définitivement le Trait si la valeur tombe à 0.
const ponctuelUsed = this.getPonctuelUsed();
const tool = this.getSelectedTool();
if (tool && ponctuelUsed > 0) {
const current = VermineTraits.traitValue(tool, 'ponctuel');
const remaining = current - ponctuelUsed;
if (remaining <= 0) {
await tool.update({ "system.traits.ponctuel": null });
} else {
await tool.update({ "system.traits.ponctuel.value": remaining });
}
}
const intimidant = this.getIntimidant();
const rollParams = {
actor: this.#actor,
NoD: this.getDicePool(),
@@ -470,6 +553,10 @@ export default class RollDialog extends HandlebarsApplicationMixin(foundry.appli
targets: [...game.user.targets].map(t => t.name),
messageFlags: this.defense ? { "vermine-defense": this.defense } : null
};
if (intimidant) {
rollParams.intimidant = { used: true, psychology: this.getSelectedSkillKey() === 'psychology' };
}
if (ponctuelUsed > 0) rollParams.ponctuelUsed = ponctuelUsed;
// Fermeture immédiate du dialogue : le jet (et l'animation 3D) continue
// en arrière-plan sans bloquer la fermeture.