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
+86 -9
View File
@@ -369,6 +369,12 @@ export class VermineExchange {
html.querySelectorAll('[data-action="exchange-apply"]').forEach(btn => {
btn.addEventListener('click', ev => this.#onApply(ev, message))
})
html.querySelectorAll('.exchange-incapacitant').forEach(btn => {
btn.addEventListener('click', ev => this.#onIncapacitant(ev, message))
})
html.querySelectorAll('.exchange-zone').forEach(btn => {
btn.addEventListener('click', ev => this.#onApplyZone(ev, message))
})
}
static async #onDefense(ev, attackMessage) {
@@ -407,7 +413,7 @@ export class VermineExchange {
const dialog = await NpcRollDialog.create({
actor,
rolltype: 'action',
locks: { difficulty: exchange.difficulty },
locks: { difficulty: exchange.difficulty, handicap: exchange.rapideHandicap ?? null },
defense
})
if (dialog) dialog.render(true)
@@ -426,14 +432,16 @@ export class VermineExchange {
}
static #defenseLocks(actor, exchange, type) {
if (type === 'parade') {
return { ability: 'vigor', skill: this.#parrySkill(actor), difficulty: exchange.difficulty }
}
return {
ability: 'reflexes',
skill: exchange.mode === 'ranged' ? 'alertness' : 'close',
difficulty: exchange.difficulty
}
const locks = type === 'parade'
? { ability: 'vigor', skill: this.#parrySkill(actor), difficulty: exchange.difficulty }
: {
ability: 'reflexes',
skill: exchange.mode === 'ranged' ? 'alertness' : 'close',
difficulty: exchange.difficulty
}
// Trait Rapide (n) : le défenseur subit (n) Handicaps sur sa défense.
if (exchange.rapideHandicap) locks.handicap = exchange.rapideHandicap
return locks
}
static #parrySkill(actor) {
@@ -479,6 +487,75 @@ export class VermineExchange {
if (msgEl) await attackMessage.update({ content: msgEl.outerHTML })
}
static async #onIncapacitant(ev) {
ev.preventDefault()
ev.stopPropagation()
const btn = ev.currentTarget
const actor = await this.#resolveDefender({ id: btn.dataset.targetId, uuid: btn.dataset.targetUuid })
if (!actor) {
ui.notifications.warn(game.i18n.localize('VERMINE.error_no_actor_selected'))
return
}
if (!game.user.isGM && !actor.isOwner) {
ui.notifications.warn(game.i18n.localize('VERMINE.error_no_permission'))
return
}
const value = parseInt(btn.dataset.value, 10) || 7
if (actor.type === 'character') {
const { default: RollDialog } = await import('./dialogs/rollDialog.mjs')
const dialog = await RollDialog.create({
actor,
rolltype: 'ability',
label: 'health',
locks: { ability: 'health', difficulty: value }
})
if (dialog) dialog.render(true)
return
}
const { default: NpcRollDialog } = await import('./dialogs/npcRollDialog.mjs')
const dialog = await NpcRollDialog.create({
actor,
rolltype: 'action',
locks: { difficulty: value }
})
if (dialog) dialog.render(true)
}
/**
* Trait Zone (n) : applique la blessure résolue à toutes les cibles de
* l'attaque présentes dans la zone (confort pour le MJ).
*/
static async #onApplyZone(ev, attackMessage) {
ev.preventDefault()
ev.stopPropagation()
const btn = ev.currentTarget
const exchange = attackMessage.getFlag('world', FLAG_EXCHANGE)
if (!exchange) return
if (!game.user.isGM && !attackMessage.isOwner) {
ui.notifications.warn(game.i18n.localize('VERMINE.error_no_permission'))
return
}
if (!exchange.targets?.length) {
ui.notifications.warn(game.i18n.localize('VERMINE.error_no_target'))
return
}
let applied = 0
for (const target of exchange.targets) {
const r = await this.resolve(attackMessage, target?.id ?? null)
if (!r?.hit || !r.woundCategory) continue
const defender = await this.#resolveDefender({ id: r.defenderId, uuid: r.defenderUuid })
if (!defender || !this.canApplyWound(defender)) continue
const result = await this.applyWound(defender, r.woundCategory)
if (result && result !== false) applied += 1
}
btn.disabled = true
btn.innerHTML = `<i class="fas fa-check"></i> ${game.i18n.format('VERMINE.zone_applied', { count: applied })}`
if (game.user.isGM || attackMessage.isOwner) {
const msgEl = btn.closest('.vermine-roll-message')
if (msgEl) await attackMessage.update({ content: msgEl.outerHTML })
}
}
static async #onApply(ev, attackMessage) {
ev.preventDefault()
const btn = ev.currentTarget