/** * 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} 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} 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' ? `${t('VERMINE.missed_parade')}` : `${t('VERMINE.missed_esquive')}`) : (r.defenseType === 'parade' ? `${t('VERMINE.hit_parade')}` : `${t('VERMINE.hit')}`) const lines = [ `
${t('VERMINE.attacker_successes')}: ${r.attackerSuccesses} · ${t('VERMINE.defender_successes')}: ${r.defenderSuccesses} → ${verdict}
` ] if (r.hit) { lines.push(`
${t('VERMINE.raw_damage')}: ${r.damage}${r.protection ? ` − ${t('ADVERSITY.protection')}: ${r.protection}` : ''} = ${t('VERMINE.net_damage')}: ${r.netDamage}
`) if (r.woundCategory) { lines.push(`
${t('VERMINE.wound')}: ${t(this.woundLabelKey(r.woundCategory))} (${t('VERMINE.wounds.threshold')} ${r.woundThreshold}D)
`) } else { lines.push(`
${t('VERMINE.no_wound')}
`) } } else { lines.push(`
${t('VERMINE.no_damage')}
`) } const apply = (r.hit && r.woundCategory && r.canApply) ? `` : '' return `
${t('VERMINE.resolve_result')}
${lines.join('')} ${apply}
` } // ── 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 = ` ${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} */ 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 } }