Résout par uuid avant l'id d'acteur : pour un token non lié (PNJ mineur), token.actor.id expose encore l'id de l'acteur monde dont il est issu, et résoudre par id blessait alors tous les tokens de la fiche. Le uuid d'un acteur synthétique est résolu en extrayant directement la scène et le token, car fromUuid échoue sur les documents non indexés quand la résolution est asynchrone.
656 lines
26 KiB
JavaScript
656 lines
26 KiB
JavaScript
/**
|
|
* Moteur de résolution des échanges de coups.
|
|
*
|
|
* Gère l'attaque (contact / distance), la défense (esquive / parade),
|
|
* le calcul des dégâts, la déduction des protections et l'application
|
|
* des blessures pour les personnages, PNJ et créatures.
|
|
*
|
|
* Le flux est assisté : les cartes de chat calculent et affichent tout,
|
|
* un bouton "Appliquer" coche la blessure sur l'acteur cible.
|
|
*
|
|
* Liens entre messages (flags "world") :
|
|
* - "vermine-exchange" sur le message d'attaque
|
|
* - "vermine-defense" sur le message de défense
|
|
*/
|
|
|
|
const FLAG_EXCHANGE = 'vermine-exchange'
|
|
const FLAG_DEFENSE = 'vermine-defense'
|
|
|
|
export class VermineExchange {
|
|
|
|
// ── Tables de difficulté ────────────────────────────────────────────
|
|
|
|
/**
|
|
* Capacité d'attaque selon le mode.
|
|
* @param {string} mode 'contact' ou 'ranged'
|
|
* @returns {string} clé de capacité (vigor / precision)
|
|
*/
|
|
static attackAbility(mode) {
|
|
return mode === 'ranged' ? 'precision' : 'vigor'
|
|
}
|
|
|
|
/**
|
|
* Difficultés d'attaque au contact selon le niveau de compétence (0-5).
|
|
* Incompétent/Débutant = 7, Confirmé = 5/7, Expert = 5/7/9,
|
|
* Maître/Légende = 3/5/7/9.
|
|
* @param {number} skillLevel valeur de compétence (0-5)
|
|
* @returns {number[]}
|
|
*/
|
|
static contactDifficultyOptions(skillLevel) {
|
|
switch (parseInt(skillLevel, 10) || 0) {
|
|
case 2: return [5, 7]
|
|
case 3: return [5, 7, 9]
|
|
case 4:
|
|
case 5: return [3, 5, 7, 9]
|
|
default: return [7]
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Difficultés d'attaque à distance par portée.
|
|
* @returns {{key: string, label: string, difficulty: number}[]}
|
|
*/
|
|
static rangeDifficultyOptions() {
|
|
return [
|
|
{ key: 'short', label: 'VERMINE.range_short', difficulty: 5 },
|
|
{ key: 'mid', label: 'VERMINE.range_mid', difficulty: 7 },
|
|
{ key: 'long', label: 'VERMINE.range_long', difficulty: 9 }
|
|
]
|
|
}
|
|
|
|
/**
|
|
* Difficultés proposées au PNJ selon son niveau d'expérience
|
|
* (champ "contact" : "7", "5 ou 7", "5,7 ou 9", "3,5,7 ou 9").
|
|
* @param {Actor} actor
|
|
* @returns {number[]}
|
|
*/
|
|
static npcDifficultyOptions(actor) {
|
|
const level = actor.system?.experience?.value || 1
|
|
const cfg = CONFIG.VERMINE.npcExperienceLevels?.[level] || {}
|
|
const raw = cfg.contact || '7'
|
|
return raw.split(',').flatMap(part => part.trim().split(/\s+ou\s+/).map(Number))
|
|
}
|
|
|
|
/**
|
|
* Mode d'attaque déduit de la compétence de l'arme.
|
|
* @param {Item|null} weapon
|
|
* @returns {string} 'contact' ou 'ranged'
|
|
*/
|
|
static modeForWeapon(weapon) {
|
|
const skill = weapon?.system?.skill || ''
|
|
return ['firearms', 'archery', 'throwing'].includes(skill) ? 'ranged' : 'contact'
|
|
}
|
|
|
|
// ── Pool et dégâts ──────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Pool d'attaque de base (sans les bonus optionnels du dialogue).
|
|
* Personnage : caractéristique + pool de compétence.
|
|
* PNJ : pool d'attaque du niveau de menace.
|
|
* Créature : pool d'attaque calculé.
|
|
* @param {Actor} actor L'attaquant
|
|
* @param {Item|null} weapon
|
|
* @param {string} mode
|
|
* @returns {number}
|
|
*/
|
|
static baseAttackPool(actor, weapon, mode) {
|
|
const type = actor.type
|
|
if (type === 'creature') {
|
|
return actor.system?.computed?.attack || 0
|
|
}
|
|
if (type === 'npc') {
|
|
const threat = actor.system?.threat?.value || 1
|
|
return CONFIG.VERMINE.npcThreatLevels?.[threat]?.attack || 0
|
|
}
|
|
const abilityKey = this.attackAbility(mode)
|
|
const ability = actor.system?.abilities?.[abilityKey]?.value || 0
|
|
const skillKey = weapon?.system?.skill
|
|
const skillValue = skillKey ? actor.system?.skills?.[skillKey]?.value || 0 : 0
|
|
const skillPool = skillValue ? CONFIG.VERMINE.SkillLevels?.[skillValue]?.dicePool || 0 : 0
|
|
return ability + skillPool
|
|
}
|
|
|
|
/**
|
|
* Dégâts de base de l'arme (hors réussites de l'attaque).
|
|
* Le trait "Vigueur+" ajoute la vigueur de l'attaquant.
|
|
* PNJ sans arme et créatures utilisent leurs dégâts propres. Une créature
|
|
* équipée d'une arme (morsure, griffes…) utilise les dégâts de celle-ci.
|
|
* @param {Actor} actor L'attaquant
|
|
* @param {Item|null} weapon
|
|
* @returns {number}
|
|
*/
|
|
static baseDamage(actor, weapon) {
|
|
const type = actor.type
|
|
if (type === 'creature') {
|
|
if (weapon?.system?.damage) {
|
|
let base = weapon.system.damage.value || 0
|
|
if (weapon.system.damage.addVigor) base += actor.system?.computed?.vigor || 0
|
|
return base
|
|
}
|
|
return actor.system?.computed?.damage || 0
|
|
}
|
|
if (weapon?.system?.damage) {
|
|
let base = weapon.system.damage.value || 0
|
|
if (weapon.system.damage.addVigor) base += this.#vigor(actor)
|
|
return base
|
|
}
|
|
return this.#vigor(actor)
|
|
}
|
|
|
|
static #vigor(actor) {
|
|
if (actor.type === 'npc') {
|
|
const threat = actor.system?.threat?.value || 1
|
|
return CONFIG.VERMINE.npcThreatLevels?.[threat]?.vigor || 0
|
|
}
|
|
if (actor.type === 'creature') return actor.system?.computed?.vigor || 0
|
|
return actor.system?.abilities?.vigor?.value || 0
|
|
}
|
|
|
|
// ── Protection et blessures ─────────────────────────────────────────
|
|
|
|
/**
|
|
* Protection du défenseur.
|
|
* Parade : seule la protection utilisée compte.
|
|
* Sinon : somme des protections équipées (créature : protection de rôle).
|
|
* @param {Actor} defender
|
|
* @param {number|null} parryLevel Protection utilisée lors d'une parade
|
|
* @returns {number}
|
|
*/
|
|
static protectionTotal(defender, parryLevel = null) {
|
|
if (parryLevel !== null) return Math.max(0, parryLevel)
|
|
if (defender.type === 'creature') {
|
|
return defender.system?.computed?.protection || 0
|
|
}
|
|
const defenses = defender.itemTypes?.defense?.filter(d => d.system.equipped) || []
|
|
return defenses.reduce((sum, d) => sum + (d.system.level || 0), 0)
|
|
}
|
|
|
|
/**
|
|
* Catégorie de blessure : le seuil le plus élevé inférieur ou égal
|
|
* aux dégâts nets subis.
|
|
* @param {number} netDamage
|
|
* @param {Actor} defender
|
|
* @returns {string|null} 'minorWound', 'majorWound', 'deadlyWound' ou null
|
|
*/
|
|
static highestWound(netDamage, defender) {
|
|
const sys = defender.system
|
|
const deadly = sys?.deadlyWound?.threshold ?? Number.POSITIVE_INFINITY
|
|
const major = sys?.majorWound?.threshold ?? Number.POSITIVE_INFINITY
|
|
const minor = sys?.minorWound?.threshold ?? Number.POSITIVE_INFINITY
|
|
if (netDamage >= deadly) return 'deadlyWound'
|
|
if (netDamage >= major) return 'majorWound'
|
|
if (netDamage >= minor) return 'minorWound'
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Clé i18n du libellé d'une catégorie de blessure.
|
|
* @param {string|null} category
|
|
* @returns {string}
|
|
*/
|
|
static woundLabelKey(category) {
|
|
if (category === 'deadlyWound') return 'VERMINE.wounds.deadly_wounds'
|
|
if (category === 'majorWound') return 'VERMINE.wounds.heavy_wounds'
|
|
if (category === 'minorWound') return 'VERMINE.wounds.light_wounds'
|
|
return 'VERMINE.no_wound'
|
|
}
|
|
|
|
/**
|
|
* Vérifie que l'utilisateur peut appliquer une blessure sur l'acteur.
|
|
* @param {Actor} actor
|
|
* @returns {boolean}
|
|
*/
|
|
static canApplyWound(actor) {
|
|
return Boolean(actor) && (game.user.isGM || actor.isOwner)
|
|
}
|
|
|
|
/** Ordre de gravité des blessures (croissant). */
|
|
static WOUND_ORDER = ['minorWound', 'majorWound', 'deadlyWound']
|
|
|
|
/** Malus de la pire blessure (règles p.42) : -1D Légère, -2D Grave, -3D Mortelle. */
|
|
static WOUND_MALUS = { minorWound: 1, majorWound: 2, deadlyWound: 3 }
|
|
|
|
/**
|
|
* Malus de blessure applicable à toutes les actions : seul le Malus de la
|
|
* Blessure la plus grave compte (non cumulatif).
|
|
* @param {Actor} actor
|
|
* @returns {number} 0, 1, 2 ou 3
|
|
*/
|
|
static woundMalus(actor) {
|
|
if (!actor?.system) return 0
|
|
if (actor.system.deadlyWound?.value > 0) return 3
|
|
if (actor.system.majorWound?.value > 0) return 2
|
|
if (actor.system.minorWound?.value > 0) return 1
|
|
return 0
|
|
}
|
|
|
|
/**
|
|
* Applique +1 cercle de blessure, en montant en gravité si les cercles du
|
|
* niveau correspondant sont déjà pleins (règles p.42 : si tous les cercles
|
|
* d'une Blessure sont cochés, la suivante est de gravité supérieure).
|
|
* @param {Actor} actor
|
|
* @param {string} woundCategory 'minorWound' | 'majorWound' | 'deadlyWound'
|
|
* @returns {Promise<Object|boolean|null>}
|
|
* - false si refusé (permissions) ou null si invalide
|
|
* - { category, value, escalated, death } si appliqué (death si tout est plein)
|
|
*/
|
|
static async applyWound(actor, woundCategory) {
|
|
if (!actor || !woundCategory) return null
|
|
if (!this.canApplyWound(actor)) return false
|
|
const startIdx = this.WOUND_ORDER.indexOf(woundCategory)
|
|
if (startIdx === -1) return null
|
|
for (let i = startIdx; i < this.WOUND_ORDER.length; i++) {
|
|
const cat = this.WOUND_ORDER[i]
|
|
const sys = actor.system?.[cat]
|
|
const max = sys?.max ?? Number.POSITIVE_INFINITY
|
|
const value = sys?.value || 0
|
|
if (value < max) {
|
|
await actor.update({ [`system.${cat}.value`]: value + 1 })
|
|
return { category: cat, value: value + 1, escalated: cat !== woundCategory, death: false }
|
|
}
|
|
}
|
|
return { category: woundCategory, value: null, escalated: false, death: true }
|
|
}
|
|
|
|
// ── Résolution ──────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Résout l'échange pour une cible donnée (défense la plus récente liée
|
|
* à cette cible, ou abstention si aucune défense).
|
|
* @param {ChatMessage} attackMessage
|
|
* @param {Object|string|null} targetRef cible de l'attaque (ou son uuid),
|
|
* défaut : première cible — le matching se fait par uuid d'abord car des
|
|
* PNJ mineurs (tokens non liés) issus de la même fiche partagent le même id
|
|
* @returns {Promise<Object|null>} résultat de la résolution
|
|
*/
|
|
static async resolve(attackMessage, targetRef = null) {
|
|
const exchange = attackMessage.getFlag('world', FLAG_EXCHANGE)
|
|
if (!exchange) return null
|
|
|
|
const target = this.#matchTarget(exchange.targets || [], targetRef)
|
|
if (!target) return null
|
|
|
|
const defs = this.#defenseMessagesFor(exchange.id, target)
|
|
const defenderMessage = defs[defs.length - 1]
|
|
const defense = defenderMessage ? defenderMessage.getFlag('world', FLAG_DEFENSE) : null
|
|
|
|
const attackerSuccesses = this.#messageTotal(attackMessage)
|
|
const defenderSuccesses = defense ? this.#messageTotal(defenderMessage) : 0
|
|
const hit = attackerSuccesses > defenderSuccesses
|
|
|
|
// Le flag de défense porte actorId/actorUuid (clés différentes de celles
|
|
// des cibles) : on normalise la référence avant résolution de l'acteur.
|
|
const defenderRef = defense
|
|
? { id: defense.actorId ?? null, uuid: defense.actorUuid ?? null, name: defense.actorName ?? '' }
|
|
: target
|
|
const defender = await this.resolveDefender(defenderRef)
|
|
|
|
const damage = (exchange.baseDamage || 0) + attackerSuccesses
|
|
const parryLevel = defense?.type === 'parade' ? (defense.parryLevel ?? 0) : null
|
|
const protection = hit && parryLevel !== null
|
|
? parryLevel
|
|
: (defender ? this.protectionTotal(defender, hit ? null : parryLevel) : 0)
|
|
const netDamage = hit ? Math.max(0, damage - protection) : 0
|
|
const woundCategory = hit && defender ? this.highestWound(netDamage, defender) : null
|
|
|
|
return {
|
|
exchangeId: exchange.id,
|
|
defenderId: defenderRef.id ?? null,
|
|
defenderUuid: defenderRef.uuid ?? null,
|
|
defenderName: defense?.actorName || target.name || '',
|
|
defenseType: defense?.type || null,
|
|
attackerSuccesses,
|
|
defenderSuccesses,
|
|
hit,
|
|
damage,
|
|
protection,
|
|
netDamage,
|
|
woundCategory,
|
|
woundThreshold: woundCategory && defender ? defender.system[woundCategory].threshold : null,
|
|
canApply: Boolean(defender) && this.canApplyWound(defender)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rend le bloc de résolution (HTML) pour insertion dans le message.
|
|
* @param {Object} r Résultat de VermineExchange.resolve
|
|
* @returns {string}
|
|
*/
|
|
static renderResolution(r) {
|
|
const t = (key) => game.i18n.localize(key)
|
|
const verdict = !r.hit
|
|
? (r.defenseType === 'parade'
|
|
? `<span class="res-parried">${t('VERMINE.missed_parade')}</span>`
|
|
: `<span class="res-dodged">${t('VERMINE.missed_esquive')}</span>`)
|
|
: (r.defenseType === 'parade'
|
|
? `<span class="res-hit">${t('VERMINE.hit_parade')}</span>`
|
|
: `<span class="res-hit">${t('VERMINE.hit')}</span>`)
|
|
|
|
const lines = [
|
|
`<div class="resolution-line">${t('VERMINE.attacker_successes')}: ${r.attackerSuccesses} · ${t('VERMINE.defender_successes')}: ${r.defenderSuccesses} → ${verdict}</div>`
|
|
]
|
|
if (r.hit) {
|
|
lines.push(`<div class="resolution-line">${t('VERMINE.raw_damage')}: <strong>${r.damage}</strong>${r.protection ? ` − ${t('ADVERSITY.protection')}: ${r.protection}` : ''} = ${t('VERMINE.net_damage')}: <strong>${r.netDamage}</strong></div>`)
|
|
if (r.woundCategory) {
|
|
lines.push(`<div class="resolution-line res-wound">${t('VERMINE.wound')}: <strong>${t(this.woundLabelKey(r.woundCategory))}</strong> (${t('VERMINE.wounds.threshold')} ${r.woundThreshold}D)</div>`)
|
|
} else {
|
|
lines.push(`<div class="resolution-line res-noshock">${t('VERMINE.no_wound')}</div>`)
|
|
}
|
|
} else {
|
|
lines.push(`<div class="resolution-line res-nodamage">${t('VERMINE.no_damage')}</div>`)
|
|
}
|
|
|
|
const apply = (r.hit && r.woundCategory && r.canApply)
|
|
? `<button type="button" class="exchange-apply" data-action="exchange-apply" data-defender-id="${r.defenderId}" data-defender-uuid="${r.defenderUuid}" data-wound="${r.woundCategory}"><i class="fas fa-briefcase-medical"></i> ${t('VERMINE.apply_wound')}</button>`
|
|
: ''
|
|
|
|
return `<div class="exchange-resolution">
|
|
<div class="resolution-header"><i class="fas fa-exchange-alt"></i> ${t('VERMINE.resolve_result')}</div>
|
|
${lines.join('')}
|
|
${apply}
|
|
</div>`
|
|
}
|
|
|
|
// ── Liaison chat ────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Lie les interactions d'échange de coups sur un message de chat.
|
|
* @param {HTMLElement} html élément du message
|
|
* @param {ChatMessage} message
|
|
*/
|
|
static bindChat(html, message) {
|
|
const exchange = message.getFlag('world', FLAG_EXCHANGE)
|
|
if (exchange) {
|
|
html.querySelectorAll('.exchange-defend').forEach(btn => {
|
|
btn.addEventListener('click', ev => this.#onDefense(ev, message))
|
|
})
|
|
html.querySelectorAll('.exchange-resolve').forEach(btn => {
|
|
btn.addEventListener('click', ev => this.#onResolve(ev, message))
|
|
})
|
|
}
|
|
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) {
|
|
ev.preventDefault()
|
|
ev.stopPropagation()
|
|
const btn = ev.currentTarget
|
|
const exchange = attackMessage.getFlag('world', FLAG_EXCHANGE)
|
|
if (!exchange) return
|
|
const type = btn.dataset.type
|
|
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
|
|
}
|
|
if (actor.type === 'creature') {
|
|
ui.notifications.warn(game.i18n.localize('VERMINE.error_creature_no_defense'))
|
|
return
|
|
}
|
|
const defense = {
|
|
exchangeId: exchange.id,
|
|
type,
|
|
actorId: actor.id,
|
|
actorUuid: actor.uuid,
|
|
actorName: actor.name,
|
|
difficulty: exchange.difficulty,
|
|
parryLevel: type === 'parade' ? this.#parryLevel(actor) : null
|
|
}
|
|
// PNJ : pas de caractéristiques/compétences — la défense utilise la valeur
|
|
// d'Action (Expérience), difficulté verrouillée.
|
|
if (actor.type === 'npc') {
|
|
const { default: NpcRollDialog } = await import('./dialogs/npcRollDialog.mjs')
|
|
const dialog = await NpcRollDialog.create({
|
|
actor,
|
|
rolltype: 'action',
|
|
locks: { difficulty: exchange.difficulty, handicap: exchange.rapideHandicap ?? null },
|
|
defense
|
|
})
|
|
if (dialog) dialog.render(true)
|
|
return
|
|
}
|
|
const { default: RollDialog } = await import('./dialogs/rollDialog.mjs')
|
|
const locks = this.#defenseLocks(actor, exchange, type)
|
|
const dialog = await RollDialog.create({
|
|
actor,
|
|
rolltype: 'skill',
|
|
label: locks.skill,
|
|
locks,
|
|
defense
|
|
})
|
|
if (dialog) dialog.render(true)
|
|
}
|
|
|
|
static #defenseLocks(actor, exchange, type) {
|
|
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) {
|
|
const hasMelee = actor.itemTypes?.weapon?.some(w => w.system.equipped && w.system.skill === 'melee')
|
|
return hasMelee ? 'melee' : 'close'
|
|
}
|
|
|
|
static #parryLevel(actor) {
|
|
const defenses = actor.itemTypes?.defense?.filter(d => d.system.equipped) || []
|
|
return defenses.reduce((best, d) => Math.max(best, d.system.level || 0), 0)
|
|
}
|
|
|
|
static async #onResolve(ev, attackMessage) {
|
|
ev.preventDefault()
|
|
const btn = ev.currentTarget
|
|
if (!game.user.isGM && !attackMessage.isOwner) {
|
|
ui.notifications.warn(game.i18n.localize('VERMINE.error_no_permission'))
|
|
return
|
|
}
|
|
const exchange = attackMessage.getFlag('world', FLAG_EXCHANGE)
|
|
if (!exchange) return
|
|
if (!exchange.targets?.length) {
|
|
ui.notifications.warn(game.i18n.localize('VERMINE.error_no_target'))
|
|
return
|
|
}
|
|
const wrapper = btn.closest('.exchange-actions')
|
|
const container = wrapper?.parentElement
|
|
if (!container) return
|
|
container.querySelectorAll('.exchange-resolution').forEach(el => el.remove())
|
|
|
|
const targets = exchange.targets?.length ? exchange.targets : [null]
|
|
for (const target of targets) {
|
|
const result = await this.resolve(attackMessage, target)
|
|
if (!result) continue
|
|
const block = document.createElement('div')
|
|
block.innerHTML = this.renderResolution(result)
|
|
const node = block.firstElementChild
|
|
container.appendChild(node)
|
|
this.bindChat(node, attackMessage)
|
|
}
|
|
|
|
const msgEl = btn.closest('.vermine-roll-message')
|
|
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)
|
|
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
|
|
const defender = await this.resolveDefender({ id: btn.dataset.defenderId, uuid: btn.dataset.defenderUuid })
|
|
const wound = btn.dataset.wound
|
|
if (!defender || !wound) return
|
|
if (!this.canApplyWound(defender)) {
|
|
ui.notifications.warn(game.i18n.localize('VERMINE.error_no_permission'))
|
|
return
|
|
}
|
|
const result = await this.applyWound(defender, wound)
|
|
if (result === false) return
|
|
btn.disabled = true
|
|
let label = game.i18n.localize('VERMINE.wound_applied')
|
|
if (result?.death) {
|
|
label = game.i18n.localize('VERMINE.wound_death')
|
|
} else if (result?.escalated) {
|
|
const from = game.i18n.localize(this.woundLabelKey(wound))
|
|
const to = game.i18n.localize(this.woundLabelKey(result.category))
|
|
label = game.i18n.format('VERMINE.wound_escalated', { from, to })
|
|
}
|
|
btn.innerHTML = `<i class="fas fa-check"></i> ${label}`
|
|
// Persiste l'état du bouton seulement si l'utilisateur peut modifier le message
|
|
if (game.user.isGM || attackMessage.isOwner) {
|
|
const msgEl = btn.closest('.vermine-roll-message')
|
|
if (msgEl) await attackMessage.update({ content: msgEl.outerHTML })
|
|
}
|
|
}
|
|
|
|
// ── Helpers internes ────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Résout l'acteur d'une cible depuis sa référence (id d'acteur monde ou
|
|
* uuid). Le uuid est privilégié : pour un token non lié, `token.actor.id`
|
|
* expose encore l'id de l'acteur monde dont le token est issu, et résoudre
|
|
* par id blesserait alors tous les tokens liés à cette fiche (PNJ mineurs).
|
|
* Le uuid d'un acteur synthétique ("Scene.x.Token.y.Actor.z") n'est pas
|
|
* résolvable de façon fiable par fromUuid (document non indexé quand la
|
|
* résolution est asynchrone) : on extrait donc directement le token.
|
|
* @param {{id: string|null, uuid: string|null}} ref
|
|
* @returns {Promise<Actor|null>}
|
|
*/
|
|
static async resolveDefender({ id = null, uuid = null }) {
|
|
if (uuid) {
|
|
const parts = uuid.split('.')
|
|
if (parts[0] === 'Scene' && parts[2] === 'Token') {
|
|
const token = game.scenes.get(parts[1])?.tokens.get(parts[3])
|
|
return token?.actor ?? null
|
|
}
|
|
if (parts[0] === 'Actor') {
|
|
return game.actors.get(parts[1]) ?? null
|
|
}
|
|
}
|
|
if (id) {
|
|
const actor = game.actors.get(id)
|
|
if (actor) return actor
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Retrouve la cible d'un échange par uuid d'abord, puis par id. Le uuid est
|
|
* fiable pour distinguer des PNJ mineurs (tokens non liés) issus de la même
|
|
* fiche, qui partagent le même id d'acteur monde.
|
|
* @param {Object[]} targets cibles du flag d'échange
|
|
* @param {Object|string|null} ref référence de la cible (ou son uuid)
|
|
* @returns {Object|null}
|
|
*/
|
|
static #matchTarget(targets, ref) {
|
|
if (!ref) return targets[0] ?? null
|
|
const uuid = typeof ref === 'string' ? ref : (ref.uuid ?? null)
|
|
const id = typeof ref === 'string' ? null : (ref.id ?? null)
|
|
if (uuid) return targets.find(t => t.uuid === uuid) ?? null
|
|
if (id) return targets.find(t => t.id === id) ?? null
|
|
return targets[0] ?? null
|
|
}
|
|
|
|
static #defenseMessagesFor(exchangeId, target = null) {
|
|
return game.messages.contents.filter(m => {
|
|
const f = m.getFlag('world', FLAG_DEFENSE)
|
|
if (!f || f.exchangeId !== exchangeId) return false
|
|
if (!target) return true
|
|
if (target.uuid && f.actorUuid === target.uuid) return true
|
|
return f.actorId === target.id
|
|
})
|
|
}
|
|
|
|
static #messageTotal(message) {
|
|
const el = document.createElement('div')
|
|
el.innerHTML = message.content || ''
|
|
const totalEl = el.querySelector('#total')
|
|
const val = parseInt(totalEl?.innerText, 10)
|
|
return Number.isNaN(val) ? 0 : val
|
|
}
|
|
}
|