- équipement des armes et protections (champ equipped, liste des équipés, toggle) - échanges de coups automatisés (attaque/défense/résolution/blessures dans le chat) - dialogue d'attaque par type (personnage/PNJ/créature), portées et difficultés - conservation du scroll des fiches lors des modifs (option scrollable + classe active) - verrouillage des compétences PNJ en mode jeu - corrections review combat (défenseur, difficulté >10, multi-cibles, permissions)
490 lines
19 KiB
JavaScript
490 lines
19 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.
|
|
* @param {Actor} actor L'attaquant
|
|
* @param {Item|null} weapon
|
|
* @returns {number}
|
|
*/
|
|
static baseDamage(actor, weapon) {
|
|
const type = actor.type
|
|
if (type === 'creature') {
|
|
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)
|
|
}
|
|
|
|
/**
|
|
* Applique +1 dans la catégorie de blessure indiquée (plafonné au max).
|
|
* @param {Actor} actor
|
|
* @param {string} woundCategory 'minorWound' | 'majorWound' | 'deadlyWound'
|
|
* @returns {Promise<number|boolean>} nouvelle valeur, ou false si refusé
|
|
*/
|
|
static async applyWound(actor, woundCategory) {
|
|
if (!actor || !woundCategory) return null
|
|
if (!this.canApplyWound(actor)) return false
|
|
const current = actor.system?.[woundCategory]?.value || 0
|
|
const max = actor.system?.[woundCategory]?.max ?? Number.POSITIVE_INFINITY
|
|
const next = Math.min(current + 1, max)
|
|
await actor.update({ [`system.${woundCategory}.value`]: next })
|
|
return next
|
|
}
|
|
|
|
// ── 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 {string|null} targetId id d'acteur de la cible (défaut : première cible)
|
|
* @returns {Promise<Object|null>} résultat de la résolution
|
|
*/
|
|
static async resolve(attackMessage, targetId = null) {
|
|
const exchange = attackMessage.getFlag('world', FLAG_EXCHANGE)
|
|
if (!exchange) return null
|
|
|
|
const target = (targetId && exchange.targets?.find(t => t.id === targetId)) || exchange.targets?.[0] || null
|
|
if (!target) return null
|
|
|
|
const defs = this.#defenseMessagesFor(exchange.id, target.id)
|
|
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))
|
|
})
|
|
}
|
|
|
|
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 { 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: {
|
|
exchangeId: exchange.id,
|
|
type,
|
|
actorId: actor.id,
|
|
actorUuid: actor.uuid,
|
|
actorName: actor.name,
|
|
difficulty: exchange.difficulty,
|
|
parryLevel: type === 'parade' ? this.#parryLevel(actor) : null
|
|
}
|
|
})
|
|
if (dialog) dialog.render(true)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
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?.id ?? null)
|
|
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 #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 next = await this.applyWound(defender, wound)
|
|
if (next === false) return
|
|
btn.disabled = true
|
|
btn.innerHTML = `<i class="fas fa-check"></i> ${game.i18n.localize('VERMINE.wound_applied')}`
|
|
// 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 un acteur depuis son id d'acteur monde (acteurs liés) ou son
|
|
* uuid (tokens non liés via fromUuid).
|
|
* @param {{id: string|null, uuid: string|null}} ref
|
|
* @returns {Promise<Actor|null>}
|
|
*/
|
|
static async #resolveDefender({ id = null, uuid = null }) {
|
|
if (id) {
|
|
const actor = game.actors.get(id)
|
|
if (actor) return actor
|
|
}
|
|
if (uuid) {
|
|
const doc = await fromUuid(uuid)
|
|
if (!doc) return null
|
|
if (doc.documentType === 'Actor') return doc
|
|
if (doc.documentType === 'Token') return doc.actor ?? null
|
|
}
|
|
return null
|
|
}
|
|
|
|
static #defenseMessagesFor(exchangeId, targetId = null) {
|
|
return game.messages.contents.filter(m => {
|
|
const f = m.getFlag('world', FLAG_DEFENSE)
|
|
if (!f || f.exchangeId !== exchangeId) return false
|
|
if (targetId) return f.actorId === targetId
|
|
return true
|
|
})
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|