import { VermineUtils } from "../roll.mjs"; import { VermineExchange } from "../exchange.mjs"; const { HandlebarsApplicationMixin } = foundry.applications.api; /** * Dialogue d'attaque pour les échanges de coups. * Prépare le pool, la difficulté (contact / distance) et le contexte * d'échange (cibles, dégâts de base) selon le type d'acteur : * - Personnage : capacité + compétence de l'arme. * - PNJ : pool d'attaque du niveau de menace + réserves de rôle. * - Créature : pool d'attaque calculé, difficulté du statut de combat. */ export default class CombatDialog extends HandlebarsApplicationMixin(foundry.applications.api.ApplicationV2) { static DEFAULT_OPTIONS = { classes: ["vermine-roll"], tag: "form", window: { icon: "fas fa-crosshairs", resizable: false }, position: { width: 520, height: 600 }, actions: { roll: CombatDialog.#onRoll, cancel: CombatDialog.#onCancel } }; static PARTS = { main: { template: "systems/vermine2047/templates/dialogs/combat-dialog.hbs" } }; static async create({ actorId, weapon = null }) { const actor = await game.actors.get(actorId); if (!actor) { ui.notifications.warn(game.i18n.localize("VERMINE.error_no_actor_selected")); return null; } const mode = actor.type === "creature" ? "contact" : VermineExchange.modeForWeapon(weapon); return new CombatDialog({ actor, weapon, mode }); } constructor(options = {}) { super(options); this.actor = options.actor; this.weapon = options.weapon ?? null; this.mode = options.mode ?? "contact"; this.abilityKey = VermineExchange.attackAbility(this.mode); this.skillKey = this.weapon?.system?.skill || null; this.skillValue = this.skillKey ? (this.actor.system?.skills?.[this.skillKey]?.value || 0) : 0; this.skillPool = this.skillValue ? (CONFIG.VERMINE.SkillLevels?.[this.skillValue]?.dicePool || 0) : 0; this.skillReroll = this.skillValue ? (CONFIG.VERMINE.SkillLevels?.[this.skillValue]?.reroll || 0) : 0; this.basePool = VermineExchange.baseAttackPool(this.actor, this.weapon, this.mode); this.baseDamage = VermineExchange.baseDamage(this.actor, this.weapon); } get title() { return game.i18n.localize("VERMINE.attack"); } async _prepareContext() { const actor = this.actor; const isCharacter = actor.type === "character"; const isNpc = actor.type === "npc"; const isCreature = actor.type === "creature"; const targets = [...game.user.targets].map(t => ({ id: t.actor?.id ?? null, uuid: t.actor?.uuid ?? null, name: t.name, canDefend: t.actor?.type !== "creature" })); let difficultyOptions = []; let lockedDifficulty = null; let defaultDifficulty = null; if (isCreature) { const d = parseInt(actor.system?.combatStatus?.difficulty, 10) || 9; lockedDifficulty = d; difficultyOptions = [{ difficulty: d, label: "", locked: true }]; defaultDifficulty = d; } else if (this.mode === "ranged") { difficultyOptions = VermineExchange.rangeDifficultyOptions().map(o => ({ difficulty: o.difficulty, label: game.i18n.localize(o.label), rangeKey: o.key })); defaultDifficulty = difficultyOptions[0]?.difficulty ?? 7; } else if (isNpc) { difficultyOptions = VermineExchange.npcDifficultyOptions(actor).map(d => ({ difficulty: d, label: String(d) })); defaultDifficulty = difficultyOptions[0]?.difficulty ?? 7; } else { difficultyOptions = VermineExchange.contactDifficultyOptions(this.skillValue).map(d => ({ difficulty: d, label: String(d) })); defaultDifficulty = difficultyOptions[0]?.difficulty ?? 7; } // Cible de type créature : défense passive, la difficulté d'attaque est // forcée au statut de combat de la créature (toute réussite ≥1 touche). const creatureTargets = targets.filter(t => t.id && !t.canDefend); const creatureLocked = !isCreature && creatureTargets.length && creatureTargets.length === targets.length; if (creatureLocked) { const ref = creatureTargets[0]; const creature = ref.id ? game.actors.get(ref.id) : null; const actor = creature ?? (ref.uuid ? await fromUuid(ref.uuid) : null); const d = parseInt(actor?.system?.combatStatus?.difficulty, 10) || 7; lockedDifficulty = d; difficultyOptions = [{ difficulty: d, label: "", locked: true }]; defaultDifficulty = d; } const threat = actor.system?.threat?.value || 1; const role = actor.system?.role?.value || 1; return { actor, system: actor.system, config: CONFIG.VERMINE, actorType: actor.type, mode: this.mode, weapon: this.weapon, abilityKey: this.abilityKey, abilityValue: actor.system?.abilities?.[this.abilityKey]?.value || 0, skillKey: this.skillKey, skillValue: this.skillValue, basePool: this.basePool, baseDamage: this.baseDamage, isCharacter, isNpc, isCreature, creatureLocked, npcThreatPool: isNpc ? (CONFIG.VERMINE.npcThreatLevels?.[threat]?.attack || 0) : 0, npcPoolMax: isNpc ? (CONFIG.VERMINE.npcRoleLevels?.[role]?.pools || 0) : 0, difficultyOptions, lockedDifficulty, defaultDifficulty, hasAmmo: this.mode === "ranged" && (this.weapon?.system?.ammo ?? 0) > 0, availableSpecialties: actor.items.filter(i => i.type === "specialty"), availableItems: actor.items.filter(i => i.type === "item"), targets }; } async _onRender(context, options) { this.element.dataset.actorId = this.actor.id; this.element.dataset.weaponId = this.weapon?.id ?? ""; this.element.dataset.mode = this.mode; for (const inp of this.element.querySelectorAll("[data-roll]")) { inp.addEventListener("change", this.#onInputChange.bind(this)); } const selfControl = this.element.querySelector("#self_control"); if (selfControl) { selfControl.addEventListener("change", this.#onChangeSelfControl.bind(this)); } this.element.querySelector("#second-action")?.addEventListener("change", () => this.#updateUI()); this.element.querySelector("#handicap")?.addEventListener("change", () => this.#updateUI()); this.element.querySelector("#human-totem")?.addEventListener("change", () => this.#updateUI()); this.element.querySelector("#adapted-totem")?.addEventListener("change", () => this.#updateUI()); this.element.querySelector("#pools-spent")?.addEventListener("change", () => this.#updateUI()); const ability = this.element.querySelector("#ability"); if (ability && this.actor.type === "character") { const sc = this.element.querySelector("#self_control"); if (sc) sc.max = ability.value; } this.#updateUI(); } // ── Getters ────────────────────────────────────────────────────────── get #el() { return this.element; } #getDifficultyBase() { const el = this.#el.querySelector('input[name="combat-difficulty"]:checked'); return parseInt(el?.value, 10) || 7; } #getSecondAction() { return this.#el.querySelector("#second-action")?.checked ?? false; } #getHandicap() { const sel = this.#el.querySelector("#handicap"); return Math.max(0, (parseInt(sel?.value, 10) || 1) - 1); } #getSelfCtrl() { return parseInt(this.#el.querySelector("#self_control")?.value, 10) || 0; } #getPoolsSpent() { return parseInt(this.#el.querySelector("#pools-spent")?.value, 10) || 0; } #getUseAmmo() { return this.#el.querySelector("#use-ammo")?.checked ?? false; } #getTotems() { return { human: this.#el.querySelector("#human-totem")?.checked ?? false, adapted: this.#el.querySelector("#adapted-totem")?.checked ?? false }; } #getKeepTotem() { return this.#el.querySelector("#keep-totem-select")?.value ?? null; } getDifficultySelect() { return this.#getDifficultyBase() + (this.#getSecondAction() ? 2 : 0); } getSkillCategory() { if (this.actor.type !== "character" || !this.skillKey) return null; return this.actor.system.skills[this.skillKey]?.category ?? null; } getSkillLevel() { return this.actor.type === "character" && this.skillValue ? this.skillValue : null; } getReroll() { if (this.actor.type === "npc") { const exp = this.actor.system?.experience?.value || 1; return CONFIG.VERMINE.npcExperienceLevels?.[exp]?.rerolls || 0; } if (this.actor.type === "creature") return 0; return this.skillReroll; } getMaxEffort() { if (this.actor.type === "character") return this.actor.system?.abilities?.[this.abilityKey]?.value || 0; return 0; } getPoolBreakdown() { const ability = this.actor.type === "character" ? (this.actor.system?.abilities?.[this.abilityKey]?.value || 0) : 0; const skill = this.actor.type === "character" ? this.skillPool : 0; const selfControl = this.#getSelfCtrl(); const pools = this.actor.type === "npc" ? this.#getPoolsSpent() : 0; const specialty = this.#hasSpecialtySelected() ? 1 : 0; const help = this.#el.querySelector("#helped")?.checked ? 1 : 0; const toolsChecked = this.#el.querySelector("input[name='usingTools']:checked"); const tooling = toolsChecked && toolsChecked.value !== "0" ? 1 : 0; const group = parseInt(this.#el.querySelector("#group")?.value, 10) || 0; const total = this.basePool + selfControl + pools + specialty + help + tooling + group; return { ability, skill, selfControl, pools, specialty, help, tooling, group, total }; } getDicePool() { return this.getPoolBreakdown().total; } #hasSpecialtySelected() { const checked = this.#el.querySelector("input[name='usingSpecialization']:checked"); return Boolean(checked && checked.value !== "aucune"); } getSpecialtyName() { const checked = this.#el.querySelector("input[name='usingSpecialization']:checked"); return checked && checked.value !== "aucune" ? checked.value : null; } getLabel() { const base = this.mode === "ranged" ? game.i18n.localize("VERMINE.attack_ranged") : game.i18n.localize("VERMINE.attack_contact"); return this.weapon ? `${base} (${this.weapon.name})` : base; } // ── UI ─────────────────────────────────────────────────────────────── #updateUI() { const total = this.getDicePool(); const totalEl = this.#el.querySelector("#dice-pool-total"); if (totalEl) totalEl.textContent = `${total}D`; const finalEls = this.#el.querySelectorAll("#final-difficulty"); for (const el of finalEls) el.textContent = this.getDifficultySelect(); const b = this.getPoolBreakdown(); const bonusEl = this.#el.querySelector("#total-bonus"); if (bonusEl) bonusEl.textContent = b.selfControl + b.pools + b.specialty + b.help + b.tooling + b.group; const handSel = this.#el.querySelector("#handicap"); const handEl = this.#el.querySelector("#current-handicap"); if (handSel && handEl) { handEl.textContent = handSel.options[handSel.selectedIndex]?.text ?? ""; } } #onInputChange() { this.#updateUI(); } #onChangeSelfControl(ev) { const valEl = this.#el.querySelector("#self_control_value"); if (valEl) valEl.textContent = ev.currentTarget.value; this.#updateUI(); } static async #onCancel(event, target) { this.close(); } static async #onRoll(event, target) { const selfCtrl = this.getPoolBreakdown().selfControl; if (selfCtrl > 0) { const current = this.actor?.system?.attributes?.self_control?.value ?? 0; if (current < selfCtrl) { ui.notifications.warn(game.i18n.localize("VERMINE.error_not_enough_self_control")); return; } } const finalDifficulty = this.getDifficultySelect(); // Consommation de munitions (armes à distance) if (this.mode === "ranged" && this.#getUseAmmo() && this.weapon) { const ammo = this.weapon.system.ammo || 0; if (ammo > 0) { await this.weapon.update({ "system.ammo": ammo - 1 }); } } if (selfCtrl > 0) { const newVal = this.actor.system.attributes.self_control.value - selfCtrl; await this.actor.update({ "system.attributes.self_control.value": newVal }); } const targets = [...game.user.targets].map(t => ({ id: t.actor?.id ?? null, uuid: t.actor?.uuid ?? null, name: t.name, canDefend: t.actor?.type !== "creature" })); const attack = { id: foundry.utils.randomID(), attackerId: this.actor.id, attackerName: this.actor.name, mode: this.mode, difficulty: finalDifficulty, range: this.mode === "ranged" ? this.#getRangeKey() : null, secondAction: this.#getSecondAction(), baseDamage: this.baseDamage, targets, weapon: this.weapon ? { name: this.weapon.name, img: this.weapon.img, damage: foundry.utils.duplicate(this.weapon.system.damage) } : null }; await VermineUtils.roll({ actor: this.actor, NoD: this.getDicePool(), Reroll: this.getReroll(), difficulty: finalDifficulty, handicap: this.#getHandicap(), rollLabel: this.getLabel(), totems: this.#getTotems(), self_control: selfCtrl, max_effort: this.getMaxEffort(), keepTotem: this.#getKeepTotem(), skillCategory: this.getSkillCategory(), skillLevel: this.getSkillLevel(), hasSpecialty: this.#hasSpecialtySelected(), poolBreakdown: this.getPoolBreakdown(), specialtyName: this.getSpecialtyName(), weapon: this.weapon?.toObject() ?? null, targets: targets.map(t => t.name), attack, messageFlags: { "vermine-exchange": attack } }); this.close(); } #getRangeKey() { const el = this.#el.querySelector('input[name="combat-difficulty"]:checked'); return el?.dataset?.rangeKey ?? null; } }