import { LesOubliesUtility } from "./les-oublies-utility.js" const PRIME_DEFINITIONS = [ { id: "none", label: "Aucune prime", type: "prime", actionTypes: ["all"], effects: {}, }, { id: "efficacite", label: "Efficacité", type: "prime", actionTypes: ["all"], effects: { finalModifier: 3, }, }, { id: "debordement", label: "Débordement", type: "prime", actionTypes: ["all"], effects: { opponentFinalModifier: -3, }, }, { id: "acceleration", label: "Accélération", type: "prime", actionTypes: ["all"], effects: { initiativeDelta: 4, }, }, { id: "blessure-grave", label: "Blessure grave", type: "prime", actionTypes: ["meleeAttack", "rangedAttack"], effects: { damageMultiplier: 1.5, }, }, { id: "blessure-precise", label: "Blessure précise", type: "prime", actionTypes: ["meleeAttack", "rangedAttack"], effects: { armorDivisor: 2, }, }, { id: "blessure-non-letale", label: "Blessure non létale", type: "prime", actionTypes: ["meleeAttack", "rangedAttack"], effects: { nonLethal: true, }, }, { id: "attaques-multiples", label: "Attaques multiples", type: "prime", actionTypes: ["meleeAttack"], effects: { damageMultiplier: 0.5, targetsMultiple: true, }, }, ] const PENALTY_DEFINITIONS = [ { id: "none", label: "Aucune pénalité", type: "penalty", actionTypes: ["all"], effects: {}, }, { id: "difficulte", label: "Difficulté", type: "penalty", actionTypes: ["all"], effects: { finalModifier: -3, }, }, { id: "facilite", label: "Facilité", type: "penalty", actionTypes: ["all"], effects: { opponentFinalModifier: 3, }, }, { id: "ralentissement", label: "Ralentissement", type: "penalty", actionTypes: ["all"], effects: { initiativeDelta: -4, }, }, { id: "danger", label: "Danger", type: "penalty", actionTypes: ["all"], effects: { nextReactionModifier: -3, }, }, { id: "abandon-position", label: "Abandon de position avantageuse", type: "penalty", actionTypes: ["all"], effects: { note: "Le personnage perd sa position avantageuse.", }, }, { id: "risque", label: "Risque", type: "penalty", actionTypes: ["all"], effects: { riskIncident: true, }, }, { id: "blessure-legere", label: "Blessure légère", type: "penalty", actionTypes: ["meleeAttack", "rangedAttack"], effects: { damageMultiplier: 0.5, }, }, ] const ATTACK_DIFFICULTIES = { melee: [ { value: 3, label: "Cible inerte (+3)" }, { value: 3, label: "Attaquant en hauteur (+3)" }, { value: 3, label: "Cible d'une taille deux fois supérieure ou plus (+3)" }, { value: -3, label: "Cible en hauteur (-3)" }, { value: -3, label: "Obscurité (-3)" }, { value: -3, label: "Cible d'une taille deux fois inférieure ou moins (-3)" }, ], ranged: [ { value: 3, label: "Cible à moins de la portée (+3)" }, { value: 3, label: "Cible très grande (+3)" }, { value: 3, label: "Cible immobile (+3)" }, { value: 0, label: "Cible à découvert (0)" }, { value: 0, label: "Entre la portée et son double (0)" }, { value: -1, label: "À genoux derrière un couvert (-1)" }, { value: -2, label: "À plat ventre derrière un couvert (-2)" }, { value: -3, label: "Cible très petite (-3)" }, { value: -3, label: "Cible en mouvement rapide (-3)" }, { value: -3, label: "Faible lumière (-3)" }, { value: -3, label: "Couvert léger (-3)" }, { value: -4, label: "Bouclier humain (-4)" }, { value: -5, label: "Seule la tête est visible (-5)" }, { value: -6, label: "Entre le double et le triple de la portée (-6)" }, ], } const MOVEMENT_DIFFICULTIES = [ { value: 3, label: "Descendre (+3)" }, { value: -3, label: "Monter (-3)" }, { value: -3, label: "Franchir un obstacle (-3)" }, { value: -3, label: "Ouvrir une porte non verrouillée (-3)" }, { value: -3, label: "Terrain difficile (-3)" }, { value: 3, label: "Se jeter à terre (+3)" }, { value: -3, label: "Se relever (-3)" }, { value: -6, label: "Ramper (-6)" }, { value: 3, label: "Courte distance (jusqu'à 10 cm) (+3)" }, { value: 3, label: "Chemin direct (+3)" }, { value: -3, label: "Longue distance (20 cm et plus) (-3)" }, { value: -3, label: "Faire un détour (-3)" }, ] const TEST_DIFFICULTIES = [ { value: 12, label: "Exceptionnellement facile (+12)" }, { value: 9, label: "Très facile (+9)" }, { value: 6, label: "Facile (+6)" }, { value: 3, label: "Avantageuse (+3)" }, { value: 0, label: "Normale (+0)" }, { value: -3, label: "Difficile (-3)" }, { value: -6, label: "Très difficile (-6)" }, { value: -9, label: "Extrêmement difficile (-9)" }, { value: -12, label: "Presque impossible (-12)" }, ] const HARVEST_SIDE_EFFECTS = { 1: "La main du personnage tremble plus ou moins violemment.", 2: "Le personnage n'arrive à trouver ni repos ni sommeil.", 3: "Le personnage se replie sur lui-même et parle très peu.", 4: "Le personnage ne trouve plus de sens à la vie et est moralement brisé.", 5: "Le personnage subit des troubles de la perception.", 6: "Le personnage continue de ressentir une peur irraisonnée.", 7: "Le personnage est tourmenté et se sent en danger.", 8: "Le personnage se ferme aux autres et n'éprouve plus d'empathie.", 9: "Le personnage a des brusques accélérations du rythme cardiaque.", 10: "Le personnage souffre d'un tic.", 11: "Le personnage n'arrive pas à se concentrer.", 12: "Le personnage est frappé de brèves amnésies.", } const PRESET_ACTIONS = { encourager: { key: "encourager", title: "Encourager un allié", mode: "test", skillKey: "commandement", difficulty: 0, hint: "Sur succès, choisissez l'avantage accordé : initiative, prochaine action ou prochaine réaction.", }, intimider: { key: "intimider", title: "Intimider un adversaire", mode: "confrontation", attackerSkillKey: "commandement", defenderSkillKey: "volonte", difficulty: 0, hint: "Sur succès, choisissez le désavantage imposé à l'adversaire.", }, evaluer: { key: "evaluer", title: "Évaluer un adversaire", mode: "confrontation", attackerSkillKey: "strategie", attackerAlternativeKeys: ["tactique"], defenderSkillKey: "subterfuge", difficulty: 0, hint: "Le paramètre observé sera rappelé dans la carte de chat si l'évaluation aboutit.", }, maitriser: { key: "maitriser", title: "Maîtriser un adversaire", mode: "confrontation", attackerSkillKey: "corpsacorps", defenderSkillKey: "corpsacorps", difficulty: 0, hint: "Sur succès, la carte rappelle les suites possibles : réduire au silence, prendre en otage, conforter sa prise, étouffer, attacher.", }, seDeplacer: { key: "seDeplacer", title: "Se déplacer", mode: "test", skillKey: "athletisme", difficulty: 0, hint: "Le premier déplacement court sans opposition peut être automatique ; lancez ce jet quand un vrai test est requis.", }, } export class LesOubliesRolls { static #actorLocks = new Map() static async openTestDialog(actor, preset = {}) { const data = await this.#promptTestOptions(actor, preset) if (!data || typeof data !== "object") return null const result = await this.resolveTest(actor, data) return this.#createChatMessage(actor, result) } static async openInitiativeDialog(actor) { const rapidite = actor.getSkillScoreByKey?.("rapidite") ?? 0 const data = await this.#promptTestOptions(actor, { label: game.i18n.localize("LESOUBLIES.rolls.initiative"), score: rapidite, difficulty: 0, rollMode: this.getDefaultRollMode(actor), extraDie: "", mode: "initiative", lockLabel: true, }) if (!data || typeof data !== "object") return null const result = await this.resolveTest(actor, data) if (!result) return null return this.#createChatMessage(actor, { ...result, mode: "initiative", initiativeScore: Math.min(Math.max(Math.ceil(result.final / 2), 0), 12), successLabel: null, }) } static async openConfrontationDialog(actor, preset = {}) { const data = await this.#promptConfrontationOptions(actor, preset) if (!data || typeof data !== "object") return null const defenderActor = this.#resolveDialogTargetActor(data.defenderActorId, preset.targetActor ?? this.#getTargetActor()) return this.#createConfrontationMessage(actor, { ...data, defenderLabel: defenderActor?.name ?? data.defenderLabel, defenderScore: defenderActor ? this.#getSkillScoreWithAlternatives(defenderActor, data.defenderSkill) : Number(data.defenderScore ?? 0), defenderSongesValue: defenderActor ? Number(defenderActor.system.songes?.value ?? 0) : Number(data.defenderSongesValue ?? 0), defenderSongesPoints: defenderActor ? Number(defenderActor.system.songes?.points ?? 0) : Number(data.defenderSongesPoints ?? 0), defenderCauchemarValue: defenderActor ? Number(defenderActor.system.cauchemar?.value ?? 0) : Number(data.defenderCauchemarValue ?? 0), defenderCauchemarPoints: defenderActor ? Number(defenderActor.system.cauchemar?.points ?? 0) : Number(data.defenderCauchemarPoints ?? 0), }, preset.actionData ?? null) } static async openAttackDialog(actor, { itemId = null, mode = null } = {}) { const weapon = itemId ? actor.items.get(itemId) ?? null : null const attackMode = mode ?? this.#getWeaponAttackMode(weapon) const targetActor = this.#getTargetActor() const data = await this.#promptAttackOptions(actor, { weapon, attackMode, targetActor, }) if (!data) return null const defenderActor = this.#resolveDialogTargetActor(data.defenderActorId, targetActor) if (!defenderActor) { ui.notifications.info("Aucune cible sélectionnée : choisissez un adversaire avant de lancer une attaque.") return null } const modifiers = this.#resolveModifierSelection(data.primeId, data.penaltyId, attackMode === "ranged" ? "rangedAttack" : "meleeAttack") const reactionOptions = this.#getAttackReactionOptions(data.attackerSkill) const actionData = { actionType: attackMode === "ranged" ? "rangedAttack" : "meleeAttack", title: attackMode === "ranged" ? "Tirer" : "Frapper", subtitle: weapon ? `${weapon.name} · ${this.#getSkillLabel(data.attackerSkill)}` : this.#getSkillLabel(data.attackerSkill), hint: attackMode === "ranged" ? "Réaction possible : Esquive ou Mêlée avec bouclier." : "Réaction possible : Corps à corps, Esquive ou Mêlée selon l'attaque.", weaponName: weapon?.name ?? (attackMode === "ranged" ? "Arme à distance" : "Arme"), modifiers, targetLabel: data.defenderLabel, notes: data.notes?.trim() || "", targetActor: defenderActor, applyToTarget: Boolean(data.applyToTarget && defenderActor), damageRequest: { actor, weapon, baseDamage: Number(data.baseDamage ?? 0), baseLabel: String(data.baseDamageLabel || weapon?.system?.damage || data.baseDamage || "0"), targetProtection: Number(data.targetProtection ?? 0), targetLabel: String(data.defenderLabel || defenderActor?.name || game.i18n.localize("LESOUBLIES.rolls.defender")), targetActor: defenderActor, applyToTarget: Boolean(data.applyToTarget && defenderActor), modifiers, }, extraContext: { difficultyLabel: data.difficultyLabel, reactionLabel: reactionOptions.find((option) => option.value === data.defenderSkill)?.label ?? this.#getSkillLabel(data.defenderSkill), }, } return this.#createConfrontationMessage(actor, { confrontationType: "directe", attackerLabel: actor.name, attackerScore: this.#getSkillScoreWithAlternatives(actor, data.attackerSkill), attackerDifficulty: Number(data.difficulty ?? 0), attackerRollMode: data.attackerRollMode, attackerExtraDie: data.attackerExtraDie, attackerFinalModifier: modifiers.summary.finalModifier, defenderLabel: data.defenderLabel, defenderScore: defenderActor ? this.#getSkillScoreWithAlternatives(defenderActor, data.defenderSkill) : Number(data.defenderScore ?? 0), defenderDifficulty: Number(data.defenderDifficulty ?? 0), defenderRollMode: data.defenderRollMode, defenderExtraDie: data.defenderExtraDie, defenderFinalModifier: modifiers.summary.opponentFinalModifier, defenderSongesValue: defenderActor ? Number(defenderActor.system.songes?.value ?? 0) : Number(data.defenderSongesValue ?? 0), defenderSongesPoints: defenderActor ? Number(defenderActor.system.songes?.points ?? 0) : Number(data.defenderSongesPoints ?? 0), defenderCauchemarValue: defenderActor ? Number(defenderActor.system.cauchemar?.value ?? 0) : Number(data.defenderCauchemarValue ?? 0), defenderCauchemarPoints: defenderActor ? Number(defenderActor.system.cauchemar?.points ?? 0) : Number(data.defenderCauchemarPoints ?? 0), }, actionData) } static async openDamageDialog(actor, { itemId = null } = {}) { const weapon = itemId ? actor.items.get(itemId) ?? null : null const targetActor = this.#getTargetActor() const data = await this.#promptDamageOptions(actor, { weapon, targetActor, }) if (!data) return null const modifiers = this.#resolveModifierSelection(data.primeId, data.penaltyId, this.#getWeaponAttackMode(weapon) === "ranged" ? "rangedAttack" : "meleeAttack") const damage = this.#computeDamageResolution({ actor, weapon, baseDamage: Number(data.baseDamage ?? 0), baseLabel: String(data.baseDamageLabel || weapon?.system?.damage || data.baseDamage || "0"), targetProtection: Number(data.targetProtection ?? 0), targetLabel: String(data.targetLabel || targetActor?.name || game.i18n.localize("LESOUBLIES.rolls.defender")), targetActor, applyToTarget: Boolean(data.applyToTarget && targetActor), modifiers, }) if (damage.applyResult) { await this.#applyDamageToActor(damage.applyResult.actor, damage.applyResult.damage) } const content = await foundry.applications.handlebars.renderTemplate( "systems/fvtt-les-oublies/templates/chat-action-roll.hbs", { actor, result: null, action: { title: "Résolution de dégâts", subtitle: weapon ? weapon.name : "Dégâts", hint: "Les dégâts des attaques standards sont fixes puis modifiés par primes, pénalités et protection.", modifiers, notes: data.notes?.trim() || "", damage, }, }, ) return ChatMessage.create({ speaker: ChatMessage.getSpeaker({ actor }), content, }) } static async openSpellDialog(actor, itemId) { const spell = actor.items.get(itemId) if (!spell) return null const data = await this.#promptSpellOptions(actor, spell) if (!data) return null const activation = await this.#withActorLock(`spell:${actor.id}`, async () => { const skill = actor.getCompetenceByKey?.(spell.system.skillKey) ?? null const skillBase = Number(skill?.system?.base ?? 0) if (skillBase < 1) { ui.notifications.warn(`Il faut au moins une base de 1 en ${this.#getSkillLabel(spell.system.skillKey)} pour activer ce sortilège.`) return null } const métierMatch = this.#actorMatchesSpellGrant(actor, spell) const surcharge = !métierMatch const effectiveCost = Number(data.actualCost ?? 0) * (surcharge ? 2 : 1) const paymentMode = String(data.paymentMode || "points") if (paymentMode === "points") { const resource = spell.system.polarity || "songes" const available = Number(actor.system?.[resource]?.points ?? 0) if (available < effectiveCost) { ui.notifications.warn(game.i18n.format("LESOUBLIES.rolls.notEnoughResourceDetailed", { resource: resource === "songes" ? game.i18n.localize("LESOUBLIES.ui.songes") : game.i18n.localize("LESOUBLIES.ui.cauchemar"), actor: actor.name, required: effectiveCost, available, })) return null } if (effectiveCost > 0) { await actor.update({ [`system.${resource}.points`]: Math.max(available - effectiveCost, 0), }) } } return { métierMatch, surcharge, effectiveCost, paymentMode } }) if (!activation) return null const content = await foundry.applications.handlebars.renderTemplate( "systems/fvtt-les-oublies/templates/chat-spell-activation.hbs", { actor, spell, activation: { targetLabel: data.targetLabel?.trim() || "Sans cible précisée", paymentMode: activation.paymentMode, actualCost: Number(data.actualCost ?? 0), effectiveCost: activation.effectiveCost, costLabel: activation.paymentMode === "points" ? `${activation.effectiveCost} point${activation.effectiveCost > 1 ? "s" : ""} de ${spell.system.polarity === "cauchemar" ? "Cauchemar" : "Songes"}` : `${activation.effectiveCost} fil${activation.effectiveCost > 1 ? "s" : ""} de ${spell.system.polarity === "cauchemar" ? "Cauchemar" : "Songes"}`, métierMatch: activation.métierMatch, surcharge: activation.surcharge, notes: data.notes?.trim() || "", }, }, ) return ChatMessage.create({ speaker: ChatMessage.getSpeaker({ actor }), content, }) } static async openCombatPresetDialog(actor, actionKey) { const preset = PRESET_ACTIONS[actionKey] if (!preset) return null if (preset.mode === "test") { const data = await this.#promptPresetTestOptions(actor, preset) if (!data) return null const modifiers = this.#resolveModifierSelection(data.primeId, data.penaltyId, data.actionType) const result = await this.resolveTest(actor, { label: preset.title, score: this.#getSkillScoreWithAlternatives(actor, preset.skillKey, preset.alternativeKeys), difficulty: Number(data.difficulty ?? preset.difficulty ?? 0), rollMode: data.rollMode, extraDie: data.extraDie, mode: "action", finalModifier: modifiers.summary.finalModifier, metadata: { action: { title: preset.title, subtitle: this.#getSkillLabel(preset.skillKey), hint: preset.hint, modifiers, notes: data.notes?.trim() || "", outcome: this.#buildPresetOutcome(actionKey, data), }, }, }) return this.#createChatMessage(actor, result) } const targetActor = this.#getTargetActor() const data = await this.#promptPresetConfrontationOptions(actor, preset, targetActor) if (!data) return null const defenderActor = this.#resolveDialogTargetActor(data.defenderActorId, targetActor) const modifiers = this.#resolveModifierSelection(data.primeId, data.penaltyId, actionKey) const actionData = { actionType: actionKey, title: preset.title, subtitle: `${this.#getSkillLabel(preset.attackerSkillKey)} contre ${this.#getSkillLabel(preset.defenderSkillKey)}`, hint: preset.hint, modifiers, notes: data.notes?.trim() || "", targetLabel: defenderActor?.name ?? data.defenderLabel, targetActor: defenderActor, applyToTarget: false, outcome: this.#buildPresetOutcome(actionKey, data), } return this.#createConfrontationMessage(actor, { confrontationType: "directe", attackerLabel: actor.name, attackerScore: this.#getSkillScoreWithAlternatives(actor, preset.attackerSkillKey, preset.attackerAlternativeKeys), attackerDifficulty: Number(data.attackerDifficulty ?? preset.difficulty ?? 0), attackerRollMode: data.attackerRollMode, attackerExtraDie: data.attackerExtraDie, attackerFinalModifier: modifiers.summary.finalModifier, defenderLabel: defenderActor?.name ?? data.defenderLabel, defenderScore: defenderActor ? this.#getSkillScoreWithAlternatives(defenderActor, preset.defenderSkillKey) : Number(data.defenderScore ?? 0), defenderDifficulty: Number(data.defenderDifficulty ?? 0), defenderRollMode: data.defenderRollMode, defenderExtraDie: data.defenderExtraDie, defenderFinalModifier: modifiers.summary.opponentFinalModifier, defenderSongesValue: defenderActor ? Number(defenderActor.system.songes?.value ?? 0) : Number(data.defenderSongesValue ?? 0), defenderSongesPoints: defenderActor ? Number(defenderActor.system.songes?.points ?? 0) : Number(data.defenderSongesPoints ?? 0), defenderCauchemarValue: defenderActor ? Number(defenderActor.system.cauchemar?.value ?? 0) : Number(data.defenderCauchemarValue ?? 0), defenderCauchemarPoints: defenderActor ? Number(defenderActor.system.cauchemar?.points ?? 0) : Number(data.defenderCauchemarPoints ?? 0), }, actionData) } static async openThreadHarvestDialog(actor) { const data = await this.#promptThreadHarvestOptions(actor) if (!data) return null const threadCount = Math.max(Number(data.threadCount ?? 1), 1) const damageTaken = threadCount const difficulty = -3 * (threadCount - 1) const result = await this.resolveTest(actor, { label: `Récolte de fils de ${data.threadType === "cauchemar" ? "Cauchemar" : "Songes"}`, score: this.#getSkillScoreWithAlternatives(actor, "onirologie"), difficulty, rollMode: data.rollMode, extraDie: data.extraDie, mode: "action", metadata: { action: { title: "Récolte de fils", subtitle: `${threadCount} fil${threadCount > 1 ? "s" : ""} de ${data.threadType === "cauchemar" ? "Cauchemar" : "Songes"}`, hint: "Le test subit -3 par fil supplémentaire souhaité et inflige 1 dégât par fil souhaité.", notes: data.notes?.trim() || "", }, }, }) if (!result) return null await this.#applyDamageToActor(actor, damageTaken) const durationRoll = await (new Roll("1d12")).evaluate() const effectRoll = await (new Roll("1d12")).evaluate() const effectIndex = Number(effectRoll.total ?? 1) result.metadata.action.harvest = { threadType: data.threadType, threadCount, damageTaken, lockoutOnFailure: !result.success, durationHours: Number(durationRoll.total ?? 0), sideEffectRoll: effectIndex, sideEffectText: HARVEST_SIDE_EFFECTS[effectIndex], sleeperLabel: data.sleeperLabel?.trim() || "Dormeur non précisé", } return this.#createChatMessage(actor, result) } static getDefaultRollMode(actor) { return actor?.type === "creature" ? "single" : "dual" } static getRollModes() { return [ { value: "dual", label: game.i18n.localize("LESOUBLIES.rolls.rollModes.dual") }, { value: "songes", label: game.i18n.localize("LESOUBLIES.rolls.rollModes.songes") }, { value: "cauchemar", label: game.i18n.localize("LESOUBLIES.rolls.rollModes.cauchemar") }, { value: "single", label: game.i18n.localize("LESOUBLIES.rolls.rollModes.single") }, ] } static getExtraDieModes() { return [ { value: "", label: game.i18n.localize("LESOUBLIES.rolls.extraDice.none") }, { value: "songes", label: game.i18n.localize("LESOUBLIES.rolls.extraDice.songes") }, { value: "cauchemar", label: game.i18n.localize("LESOUBLIES.rolls.extraDice.cauchemar") }, ] } static getWeaponActionLabel(weapon) { return this.#getWeaponAttackMode(weapon) === "ranged" ? "Tirer" : "Frapper" } static isRangedWeapon(weapon) { return this.#getWeaponAttackMode(weapon) === "ranged" } static resolveModifierSelection(primeId = "none", penaltyId = "none", actionType = "all") { return this.#resolveModifierSelection(primeId, penaltyId, actionType) } static async resolveTest(actor, options) { const rollContext = this.#getRollContext(actor) const spentResource = this.#createSpentResource(options.extraDie) if (spentResource && !this.#canSpendResource(rollContext, spentResource.type)) { ui.notifications.warn(game.i18n.format("LESOUBLIES.rolls.notEnoughResource", { resource: spentResource.label, actor: rollContext.label, })) return null } const pool = this.#buildPool(options.rollMode, options.extraDie) const dice = [] for (let index = 0; index < pool.length; index += 1) { const spec = pool[index] dice.push(await this.#rollExplodingDie({ ...spec, index, })) } const selectedIndex = this.#needsSelection(dice) ? await this.#promptDieSelection(actor, options.label, dice) : this.#selectBestDie(dice) if (!Number.isInteger(selectedIndex)) return null const selectedDie = dice[selectedIndex] const debt = this.#computeDebt(rollContext, dice, selectedDie) if (spentResource) await this.#spendResource(rollContext, spentResource) const natural = Number(selectedDie.total ?? 0) const score = Number(options.score ?? 0) const difficulty = Number(options.difficulty ?? 0) const threshold = 12 const finalModifier = Number(options.finalModifier ?? 0) const baseFinal = natural + score + difficulty const final = baseFinal + finalModifier const automaticFailure = selectedDie.firstFace === 1 const success = !automaticFailure && final >= threshold return { mode: options.mode ?? "test", label: options.label, score, difficulty, threshold, margin: final - threshold, rollMode: options.rollMode, rollModeLabel: game.i18n.localize(`LESOUBLIES.rolls.rollModes.${options.rollMode}`), dice, selectedDie, selectedSummary: `${selectedDie.typeLabel} ${selectedDie.total}`, natural, baseFinal, finalModifier, final, success, automaticFailure, successLabel: game.i18n.localize(success ? "LESOUBLIES.rolls.success" : "LESOUBLIES.rolls.failure"), choiceLabel: game.i18n.localize(debt.amount ? "LESOUBLIES.rolls.riskyChoice" : "LESOUBLIES.rolls.safeChoice"), debt, spentResource, metadata: foundry.utils.deepClone(options.metadata ?? {}), } } static async #createChatMessage(actor, result) { if (!result) return null const template = result.mode === "initiative" ? "systems/fvtt-les-oublies/templates/chat-initiative-roll.hbs" : result.mode === "action" ? "systems/fvtt-les-oublies/templates/chat-action-roll.hbs" : "systems/fvtt-les-oublies/templates/chat-test-roll.hbs" const content = await foundry.applications.handlebars.renderTemplate(template, { actor, result, action: result.metadata?.action ?? null, }) return ChatMessage.create({ speaker: ChatMessage.getSpeaker({ actor }), content, }) } static async #createConfrontationMessage(actor, data, actionData = null) { const attacker = await this.resolveTest(actor, { label: data.attackerLabel, score: data.attackerScore, difficulty: data.attackerDifficulty, rollMode: data.attackerRollMode, extraDie: data.attackerExtraDie, mode: "confrontation", finalModifier: data.attackerFinalModifier, }) const defenderContext = data.targetActor ?? this.#createVirtualRollContext({ label: data.defenderLabel, songesValue: data.defenderSongesValue, songesPoints: data.defenderSongesPoints, cauchemarValue: data.defenderCauchemarValue, cauchemarPoints: data.defenderCauchemarPoints, }) const defender = await this.resolveTest(defenderContext, { label: data.defenderLabel, score: data.defenderScore, difficulty: data.defenderDifficulty, rollMode: data.defenderRollMode, extraDie: data.defenderExtraDie, mode: "confrontation", finalModifier: data.defenderFinalModifier, }) if (!attacker || !defender) return null const outcomeKey = this.#getConfrontationOutcome(attacker, defender) const confrontationAction = foundry.utils.deepClone(actionData ?? {}) if (confrontationAction.damageRequest) { const hit = ["attacker-success", "attacker-advantage"].includes(outcomeKey) const damage = hit ? this.#computeDamageResolution(confrontationAction.damageRequest) : null confrontationAction.damage = damage if (damage?.applyResult) { await this.#applyDamageToActor(damage.applyResult.actor, damage.applyResult.damage) } } if (confrontationAction.outcome) { confrontationAction.outcome.success = ["attacker-success", "attacker-advantage"].includes(outcomeKey) } const content = await foundry.applications.handlebars.renderTemplate( "systems/fvtt-les-oublies/templates/chat-confrontation-roll.hbs", { actor, confrontationType: game.i18n.localize(`LESOUBLIES.rolls.confrontationTypes.${data.confrontationType}`), attacker, defender, outcomeKey, outcomeLabel: game.i18n.localize(`LESOUBLIES.rolls.outcomes.${outcomeKey}`), action: confrontationAction, }, ) return ChatMessage.create({ speaker: ChatMessage.getSpeaker({ actor }), content, }) } static async #promptTestOptions(actor, preset = {}) { const difficulty = Number(preset.difficulty ?? 0) const content = await foundry.applications.handlebars.renderTemplate( "systems/fvtt-les-oublies/templates/dialog-roll-test.hbs", { actor, rollModes: this.getRollModes(), extraDieModes: this.getExtraDieModes(), difficultyOptions: this.#getDifficultyOptions(TEST_DIFFICULTIES, difficulty), resources: this.#getDialogResources(actor), values: { label: preset.label ?? "", score: Number(preset.score ?? 0), difficulty, rollMode: preset.rollMode ?? this.getDefaultRollMode(actor), extraDie: preset.extraDie ?? "", }, isInitiative: preset.mode === "initiative", lockLabel: Boolean(preset.lockLabel), }, ) return foundry.applications.api.DialogV2.wait({ window: { title: game.i18n.localize( preset.mode === "initiative" ? "LESOUBLIES.rolls.dialogs.initiativeTitle" : "LESOUBLIES.rolls.dialogs.testTitle", ), }, content, buttons: [ { action: "roll", label: game.i18n.localize("LESOUBLIES.rolls.roll"), default: true, callback: async (_event, _button, dialog) => { const form = this.#getDialogElement(dialog)?.querySelector("form") if (!form) return null const data = this.#formToObject(form) return { label: String(data.label || "").trim() || preset.label || game.i18n.localize("LESOUBLIES.ui.roll"), score: Number(data.score ?? 0), difficulty: Number(data.difficulty ?? 0), rollMode: String(data.rollMode || this.getDefaultRollMode(actor)), extraDie: String(data.extraDie || ""), mode: preset.mode ?? "test", } }, }, { action: "cancel", label: game.i18n.localize("Cancel"), }, ], }) } static async #promptConfrontationOptions(actor, preset = {}) { const targetActor = preset.targetActor ?? this.#getTargetActor() const defenderSkill = preset.defenderSkill ?? "esquive" const content = await foundry.applications.handlebars.renderTemplate( "systems/fvtt-les-oublies/templates/dialog-roll-confrontation.hbs", { actor, targetActor, targetStatus: this.#getConfrontationTargetStatus(targetActor), targetOptions: this.#getConfrontationTargetOptions(actor, targetActor), defenderSkillOptions: this.#getConfrontationSkillOptions(), rollModes: this.getRollModes(), extraDieModes: this.getExtraDieModes(), defaultRollMode: this.getDefaultRollMode(actor), attackerResources: this.#getDialogResources(actor), defenderResources: targetActor ? this.#getDialogResources(targetActor) : { songesValue: 0, songesPoints: 0, cauchemarValue: 0, cauchemarPoints: 0, }, values: { attackerLabel: preset.attackerLabel ?? actor.name, attackerScore: Number(preset.attackerScore ?? 0), attackerDifficulty: Number(preset.attackerDifficulty ?? 0), attackerRollMode: preset.attackerRollMode ?? this.getDefaultRollMode(actor), attackerExtraDie: preset.attackerExtraDie ?? "", defenderLabel: targetActor?.name ?? preset.defenderLabel ?? game.i18n.localize("LESOUBLIES.rolls.defender"), defenderActorId: targetActor?.id ?? "", defenderSkill, defenderScore: targetActor ? this.#getSkillScoreWithAlternatives(targetActor, defenderSkill) : Number(preset.defenderScore ?? 0), defenderDifficulty: Number(preset.defenderDifficulty ?? 0), defenderRollMode: preset.defenderRollMode ?? this.getDefaultRollMode(targetActor ?? actor), defenderExtraDie: preset.defenderExtraDie ?? "", confrontationType: preset.confrontationType ?? "directe", }, }, ) return foundry.applications.api.DialogV2.wait({ window: { title: game.i18n.localize("LESOUBLIES.rolls.dialogs.confrontationTitle"), }, content, render: (_event, dialog) => { this.#bindConfrontationTargetSelection(dialog, { actor, fallbackTargetActor: targetActor, skillFieldName: "defenderSkill", }) }, buttons: [ { action: "roll", label: game.i18n.localize("LESOUBLIES.rolls.roll"), default: true, callback: async (_event, _button, dialog) => { const form = this.#getDialogElement(dialog)?.querySelector("form") if (!form) return null const data = this.#formToObject(form) return { confrontationType: data.confrontationType || "directe", attackerLabel: String(data.attackerLabel || actor.name || game.i18n.localize("LESOUBLIES.rolls.attacker")).trim(), attackerScore: Number(data.attackerScore ?? 0), attackerDifficulty: Number(data.attackerDifficulty ?? 0), attackerRollMode: String(data.attackerRollMode || this.getDefaultRollMode(actor)), attackerExtraDie: String(data.attackerExtraDie || ""), defenderActorId: String(data.defenderActorId || ""), defenderSkill: String(data.defenderSkill || defenderSkill), defenderLabel: String(data.defenderLabel || targetActor?.name || game.i18n.localize("LESOUBLIES.rolls.defender")).trim(), defenderScore: Number(data.defenderScore ?? 0), defenderDifficulty: Number(data.defenderDifficulty ?? 0), defenderRollMode: String(data.defenderRollMode || this.getDefaultRollMode(targetActor ?? actor)), defenderExtraDie: String(data.defenderExtraDie || ""), defenderSongesValue: targetActor ? Number(targetActor.system.songes?.value ?? 0) : Number(data.defenderSongesValue ?? 0), defenderSongesPoints: targetActor ? Number(targetActor.system.songes?.points ?? 0) : Number(data.defenderSongesPoints ?? 0), defenderCauchemarValue: targetActor ? Number(targetActor.system.cauchemar?.value ?? 0) : Number(data.defenderCauchemarValue ?? 0), defenderCauchemarPoints: targetActor ? Number(targetActor.system.cauchemar?.points ?? 0) : Number(data.defenderCauchemarPoints ?? 0), targetActor, } }, }, { action: "cancel", label: game.i18n.localize("Cancel"), }, ], }) } static async #promptAttackOptions(actor, { weapon = null, attackMode = "melee", targetActor = null } = {}) { const baseDamage = this.#getWeaponBaseDamage(actor, weapon) const baseDamageLabel = weapon?.system?.damage || String(baseDamage) const content = await foundry.applications.handlebars.renderTemplate( "systems/fvtt-les-oublies/templates/dialog-roll-attack-v2.hbs", { actor, weapon, attackTitle: attackMode === "ranged" ? "Tirer" : "Frapper", attackerResources: this.#getDialogResources(actor), defenderResources: targetActor ? this.#getDialogResources(targetActor) : { songesValue: 0, songesPoints: 0, cauchemarValue: 0, cauchemarPoints: 0, }, targetActor, targetStatus: this.#getConfrontationTargetStatus(targetActor, { requireTarget: true }), targetOptions: this.#getConfrontationTargetOptions(actor, targetActor).map((entry, index) => ( index === 0 ? { ...entry, label: "— Sélectionner un adversaire —" } : entry )), rollModes: this.getRollModes(), extraDieModes: this.getExtraDieModes(), attackSkills: this.#getAttackSkillOptions(attackMode), reactionSkills: this.#getAttackReactionOptions(attackMode === "ranged" ? "tir" : "melee"), difficultyOptions: ATTACK_DIFFICULTIES[attackMode] ?? [], primeOptions: this.#getModifierOptions("prime", attackMode === "ranged" ? "rangedAttack" : "meleeAttack"), penaltyOptions: this.#getModifierOptions("penalty", attackMode === "ranged" ? "rangedAttack" : "meleeAttack"), values: { attackerSkill: attackMode === "ranged" ? "tir" : "melee", attackerRollMode: this.getDefaultRollMode(actor), attackerExtraDie: "", defenderLabel: targetActor?.name ?? game.i18n.localize("LESOUBLIES.rolls.defender"), defenderActorId: targetActor?.id ?? "", defenderSkill: attackMode === "ranged" ? "esquive" : "esquive", defenderScore: 0, defenderDifficulty: 0, defenderRollMode: this.getDefaultRollMode(targetActor ?? actor), defenderExtraDie: "", targetProtection: this.#getActorProtection(targetActor), difficultyPreset: 0, customDifficulty: 0, primeId: "none", penaltyId: "none", baseDamage, baseDamageLabel, applyToTarget: Boolean(targetActor), notes: "", }, }, ) return foundry.applications.api.DialogV2.wait({ window: { title: attackMode === "ranged" ? "Attaque à distance" : "Attaque de mêlée", }, content, render: (_event, dialog) => { this.#bindConfrontationTargetSelection(dialog, { actor, fallbackTargetActor: targetActor, skillFieldName: "defenderSkill", requireTarget: true, }) }, buttons: [ { action: "roll", label: game.i18n.localize("LESOUBLIES.rolls.roll"), default: true, callback: async (_event, _button, dialog) => { const form = this.#getDialogElement(dialog)?.querySelector("form") if (!form) return null const data = this.#formToObject(form) const defenderActor = this.#resolveDialogTargetActor(data.defenderActorId, targetActor) if (!defenderActor) { ui.notifications.info("Aucune cible sélectionnée : choisissez un adversaire avant de lancer une attaque.") dialog.close() return null } const difficultyPreset = Number(data.difficultyPreset ?? 0) const customDifficulty = Number(data.customDifficulty ?? 0) return { attackerSkill: String(data.attackerSkill || (attackMode === "ranged" ? "tir" : "melee")), attackerRollMode: String(data.attackerRollMode || this.getDefaultRollMode(actor)), attackerExtraDie: String(data.attackerExtraDie || ""), defenderActorId: String(data.defenderActorId || ""), defenderLabel: String(data.defenderLabel || targetActor?.name || game.i18n.localize("LESOUBLIES.rolls.defender")).trim(), defenderSkill: String(data.defenderSkill || "esquive"), defenderScore: Number(data.defenderScore ?? 0), defenderDifficulty: Number(data.defenderDifficulty ?? 0), defenderRollMode: String(data.defenderRollMode || this.getDefaultRollMode(targetActor ?? actor)), defenderExtraDie: String(data.defenderExtraDie || ""), defenderSongesValue: targetActor ? Number(targetActor.system.songes?.value ?? 0) : Number(data.defenderSongesValue ?? 0), defenderSongesPoints: targetActor ? Number(targetActor.system.songes?.points ?? 0) : Number(data.defenderSongesPoints ?? 0), defenderCauchemarValue: targetActor ? Number(targetActor.system.cauchemar?.value ?? 0) : Number(data.defenderCauchemarValue ?? 0), defenderCauchemarPoints: targetActor ? Number(targetActor.system.cauchemar?.points ?? 0) : Number(data.defenderCauchemarPoints ?? 0), difficulty: difficultyPreset + customDifficulty, difficultyLabel: this.#formatDifficultyBreakdown(difficultyPreset, customDifficulty), targetProtection: Number(data.targetProtection ?? 0), primeId: String(data.primeId || "none"), penaltyId: String(data.penaltyId || "none"), baseDamage: Number(data.baseDamage ?? baseDamage), baseDamageLabel: String(data.baseDamageLabel || baseDamageLabel), applyToTarget: data.applyToTarget === "on", notes: String(data.notes || ""), } }, }, { action: "cancel", label: game.i18n.localize("Cancel"), }, ], }) } static async #promptDamageOptions(actor, { weapon = null, targetActor = null } = {}) { const baseDamage = this.#getWeaponBaseDamage(actor, weapon) const baseDamageLabel = weapon?.system?.damage || String(baseDamage) const content = await foundry.applications.handlebars.renderTemplate( "systems/fvtt-les-oublies/templates/dialog-damage-resolution.hbs", { actor, weapon, targetActor, primeOptions: this.#getModifierOptions("prime", this.#getWeaponAttackMode(weapon) === "ranged" ? "rangedAttack" : "meleeAttack"), penaltyOptions: this.#getModifierOptions("penalty", this.#getWeaponAttackMode(weapon) === "ranged" ? "rangedAttack" : "meleeAttack"), values: { targetLabel: targetActor?.name ?? game.i18n.localize("LESOUBLIES.rolls.defender"), targetProtection: this.#getActorProtection(targetActor), baseDamage, baseDamageLabel, primeId: "none", penaltyId: "none", applyToTarget: Boolean(targetActor), notes: "", }, }, ) return foundry.applications.api.DialogV2.wait({ window: { title: "Résolution de dégâts", }, content, buttons: [ { action: "resolve", label: "Résoudre", default: true, callback: async (_event, _button, dialog) => { const form = this.#getDialogElement(dialog)?.querySelector("form") if (!form) return null const data = this.#formToObject(form) return { targetLabel: String(data.targetLabel || targetActor?.name || game.i18n.localize("LESOUBLIES.rolls.defender")).trim(), targetProtection: Number(data.targetProtection ?? 0), baseDamage: Number(data.baseDamage ?? baseDamage), baseDamageLabel: String(data.baseDamageLabel || baseDamageLabel), primeId: String(data.primeId || "none"), penaltyId: String(data.penaltyId || "none"), applyToTarget: data.applyToTarget === "on", notes: String(data.notes || ""), } }, }, { action: "cancel", label: game.i18n.localize("Cancel"), }, ], }) } static async #promptSpellOptions(actor, spell) { const isMetierMatch = this.#actorMatchesSpellGrant(actor, spell) const effectiveCost = Number(spell.system.cost ?? 0) * (isMetierMatch ? 1 : 2) const polarityLabel = spell.system.polarity === "cauchemar" ? game.i18n.localize("LESOUBLIES.ui.cauchemar") : game.i18n.localize("LESOUBLIES.ui.songes") const content = await foundry.applications.handlebars.renderTemplate( "systems/fvtt-les-oublies/templates/dialog-spell-activation.hbs", { actor, spell, resources: this.#getDialogResources(actor), isMetierMatch, effectiveCostLabel: `${effectiveCost} point${effectiveCost > 1 ? "s" : ""} de ${polarityLabel}`, values: { actualCost: Number(spell.system.cost ?? 0), paymentMode: "points", targetLabel: "", notes: "", }, }, ) return foundry.applications.api.DialogV2.wait({ window: { title: `Activer ${spell.name}`, }, content, buttons: [ { action: "activate", label: "Activer", default: true, callback: async (_event, _button, dialog) => { const form = this.#getDialogElement(dialog)?.querySelector("form") if (!form) return null const data = this.#formToObject(form) return { actualCost: Number(data.actualCost ?? spell.system.cost ?? 0), paymentMode: String(data.paymentMode || "points"), targetLabel: String(data.targetLabel || ""), notes: String(data.notes || ""), } }, }, { action: "cancel", label: game.i18n.localize("Cancel"), }, ], }) } static async #promptPresetTestOptions(actor, preset) { const content = await foundry.applications.handlebars.renderTemplate( "systems/fvtt-les-oublies/templates/dialog-roll-action.hbs", { actor, title: preset.title, hint: preset.hint, rollModes: this.getRollModes(), extraDieModes: this.getExtraDieModes(), resources: this.#getDialogResources(actor), difficultyOptions: preset.key === "seDeplacer" ? MOVEMENT_DIFFICULTIES : [], primeOptions: this.#getModifierOptions("prime", preset.key), penaltyOptions: this.#getModifierOptions("penalty", preset.key), values: { rollMode: this.getDefaultRollMode(actor), extraDie: "", difficultyPreset: 0, customDifficulty: Number(preset.difficulty ?? 0), primeId: "none", penaltyId: "none", targetLabel: "", outcomeChoice: preset.key === "encourager" ? "initiative" : "", notes: "", }, showMovementOptions: preset.key === "seDeplacer", showEncourageOptions: preset.key === "encourager", }, ) return foundry.applications.api.DialogV2.wait({ window: { title: preset.title, }, content, buttons: [ { action: "roll", label: game.i18n.localize("LESOUBLIES.rolls.roll"), default: true, callback: async (_event, _button, dialog) => { const form = this.#getDialogElement(dialog)?.querySelector("form") if (!form) return null const data = this.#formToObject(form) return { actionType: preset.key, rollMode: String(data.rollMode || this.getDefaultRollMode(actor)), extraDie: String(data.extraDie || ""), difficulty: Number(data.difficultyPreset ?? 0) + Number(data.customDifficulty ?? 0), primeId: String(data.primeId || "none"), penaltyId: String(data.penaltyId || "none"), notes: String(data.notes || ""), targetLabel: String(data.targetLabel || ""), outcomeChoice: String(data.outcomeChoice || ""), } }, }, { action: "cancel", label: game.i18n.localize("Cancel"), }, ], }) } static async #promptPresetConfrontationOptions(actor, preset, targetActor = null) { const content = await foundry.applications.handlebars.renderTemplate( "systems/fvtt-les-oublies/templates/dialog-roll-preset-confrontation.hbs", { actor, title: preset.title, hint: preset.hint, targetActor, targetStatus: this.#getConfrontationTargetStatus(targetActor), defenderSkillLabel: this.#getSkillLabel(preset.defenderSkillKey), targetOptions: this.#getConfrontationTargetOptions(actor, targetActor), rollModes: this.getRollModes(), extraDieModes: this.getExtraDieModes(), attackerResources: this.#getDialogResources(actor), defenderResources: targetActor ? this.#getDialogResources(targetActor) : { songesValue: 0, songesPoints: 0, cauchemarValue: 0, cauchemarPoints: 0, }, primeOptions: this.#getModifierOptions("prime", preset.key), penaltyOptions: this.#getModifierOptions("penalty", preset.key), values: { attackerDifficulty: Number(preset.difficulty ?? 0), defenderLabel: targetActor?.name ?? game.i18n.localize("LESOUBLIES.rolls.defender"), defenderActorId: targetActor?.id ?? "", defenderDifficulty: 0, attackerRollMode: this.getDefaultRollMode(actor), attackerExtraDie: "", defenderRollMode: this.getDefaultRollMode(targetActor ?? actor), defenderExtraDie: "", defenderScore: targetActor ? this.#getSkillScoreWithAlternatives(targetActor, preset.defenderSkillKey) : 0, primeId: "none", penaltyId: "none", outcomeChoice: "", targetLabel: "", notes: "", }, showIntimidateOptions: preset.key === "intimider", showEvaluateOptions: preset.key === "evaluer", showGrappleOptions: preset.key === "maitriser", }, ) return foundry.applications.api.DialogV2.wait({ window: { title: preset.title, }, content, render: (_event, dialog) => { this.#bindConfrontationTargetSelection(dialog, { actor, fallbackTargetActor: targetActor, skillKey: preset.defenderSkillKey, }) }, buttons: [ { action: "roll", label: game.i18n.localize("LESOUBLIES.rolls.roll"), default: true, callback: async (_event, _button, dialog) => { const form = this.#getDialogElement(dialog)?.querySelector("form") if (!form) return null const data = this.#formToObject(form) return { attackerDifficulty: Number(data.attackerDifficulty ?? preset.difficulty ?? 0), defenderActorId: String(data.defenderActorId || ""), defenderLabel: String(data.defenderLabel || targetActor?.name || game.i18n.localize("LESOUBLIES.rolls.defender")).trim(), defenderDifficulty: Number(data.defenderDifficulty ?? 0), attackerRollMode: String(data.attackerRollMode || this.getDefaultRollMode(actor)), attackerExtraDie: String(data.attackerExtraDie || ""), defenderRollMode: String(data.defenderRollMode || this.getDefaultRollMode(targetActor ?? actor)), defenderExtraDie: String(data.defenderExtraDie || ""), defenderScore: Number(data.defenderScore ?? 0), defenderSongesValue: targetActor ? Number(targetActor.system.songes?.value ?? 0) : Number(data.defenderSongesValue ?? 0), defenderSongesPoints: targetActor ? Number(targetActor.system.songes?.points ?? 0) : Number(data.defenderSongesPoints ?? 0), defenderCauchemarValue: targetActor ? Number(targetActor.system.cauchemar?.value ?? 0) : Number(data.defenderCauchemarValue ?? 0), defenderCauchemarPoints: targetActor ? Number(targetActor.system.cauchemar?.points ?? 0) : Number(data.defenderCauchemarPoints ?? 0), primeId: String(data.primeId || "none"), penaltyId: String(data.penaltyId || "none"), outcomeChoice: String(data.outcomeChoice || ""), targetLabel: String(data.targetLabel || ""), notes: String(data.notes || ""), } }, }, { action: "cancel", label: game.i18n.localize("Cancel"), }, ], }) } static async #promptThreadHarvestOptions(actor) { const content = await foundry.applications.handlebars.renderTemplate( "systems/fvtt-les-oublies/templates/dialog-thread-harvest.hbs", { actor, rollModes: this.getRollModes(), extraDieModes: this.getExtraDieModes(), resources: this.#getDialogResources(actor), values: { threadType: "songes", threadCount: 1, rollMode: this.getDefaultRollMode(actor), extraDie: "", sleeperLabel: "", notes: "", }, }, ) return foundry.applications.api.DialogV2.wait({ window: { title: "Récolte de fils", }, content, buttons: [ { action: "roll", label: game.i18n.localize("LESOUBLIES.rolls.roll"), default: true, callback: async (_event, _button, dialog) => { const form = this.#getDialogElement(dialog)?.querySelector("form") if (!form) return null const data = this.#formToObject(form) return { threadType: String(data.threadType || "songes"), threadCount: Number(data.threadCount ?? 1), rollMode: String(data.rollMode || this.getDefaultRollMode(actor)), extraDie: String(data.extraDie || ""), sleeperLabel: String(data.sleeperLabel || ""), notes: String(data.notes || ""), } }, }, { action: "cancel", label: game.i18n.localize("Cancel"), }, ], }) } static async #promptDieSelection(actor, label, dice) { const content = await foundry.applications.handlebars.renderTemplate( "systems/fvtt-les-oublies/templates/dialog-roll-choice.hbs", { actor: this.#getRollContext(actor), label, dice: dice.map((die) => ({ ...die, debtLabel: this.#computeDebt(this.#getRollContext(actor), dice, die).label, })), }, ) return foundry.applications.api.DialogV2.wait({ window: { title: game.i18n.localize("LESOUBLIES.rolls.dialogs.choiceTitle"), }, content, buttons: dice.map((die) => ({ action: `select-${die.index}`, label: `${die.typeLabel} ${die.total}`, callback: async () => die.index, })).concat({ action: "cancel", label: game.i18n.localize("Cancel"), }), }) } static #buildPool(rollMode, extraDie) { const dice = [] switch (rollMode) { case "songes": dice.push({ type: "songes" }, { type: "songes" }) break case "cauchemar": dice.push({ type: "cauchemar" }, { type: "cauchemar" }) break case "single": dice.push({ type: "neutral" }) break case "dual": default: dice.push({ type: "songes" }, { type: "cauchemar" }) break } if (extraDie && rollMode !== "single") { dice.push({ type: extraDie, source: "extra" }) } return dice } static async #rollExplodingDie({ type, index, source = "base" }) { const faces = [] let total = 0 let lastFace = 12 while (lastFace === 12) { const roll = await (new Roll("1d12")).evaluate() lastFace = Number(roll.total ?? 0) faces.push(lastFace) total += lastFace } const typeLabel = game.i18n.localize(`LESOUBLIES.rolls.dice.${type}`) return { index, type, typeLabel, source, sourceLabel: source === "extra" ? game.i18n.localize("LESOUBLIES.rolls.extraDie") : null, faces, firstFace: faces[0] ?? 0, total, exploded: faces.length > 1, breakdown: faces.join(" + "), } } static #needsSelection(dice) { return new Set(dice.map((die) => die.type)).size > 1 } static #selectBestDie(dice) { return dice.reduce((bestIndex, die, index, collection) => ( die.total > collection[bestIndex].total ? index : bestIndex ), 0) } static #computeDebt(actor, dice, selectedDie) { if (!actor?.system) { return { type: null, amount: 0, label: game.i18n.localize("LESOUBLIES.rolls.noDebt"), } } const rolledPolarities = new Set( dice .map((die) => die.type) .filter((type) => ["songes", "cauchemar"].includes(type)), ) if (rolledPolarities.size < 2 || !["songes", "cauchemar"].includes(selectedDie.type)) { return { type: null, amount: 0, label: game.i18n.localize("LESOUBLIES.rolls.noDebt"), } } const songesValue = Number(actor.system.songes?.value ?? 0) const cauchemarValue = Number(actor.system.cauchemar?.value ?? 0) if (songesValue === cauchemarValue) { return { type: null, amount: 0, label: game.i18n.localize("LESOUBLIES.rolls.noDebt"), } } const dominant = songesValue > cauchemarValue ? "songes" : "cauchemar" if (selectedDie.type === dominant) { return { type: null, amount: 0, label: game.i18n.localize("LESOUBLIES.rolls.noDebt"), } } return { type: selectedDie.type, amount: 1, label: game.i18n.format("LESOUBLIES.rolls.debtGain", { type: game.i18n.localize(`LESOUBLIES.rolls.dice.${selectedDie.type}`), }), } } static #getDialogElement(dialog) { return dialog.element instanceof HTMLElement ? dialog.element : dialog.element?.[0] ?? null } static #formToObject(form) { return Object.fromEntries(new FormData(form).entries()) } static #getRollContext(actor) { if (actor?.system) return actor return actor ?? this.#createVirtualRollContext() } static #createVirtualRollContext({ label = game.i18n.localize("LESOUBLIES.rolls.defender"), songesValue = 0, songesPoints = 0, cauchemarValue = 0, cauchemarPoints = 0, } = {}) { return { name: label, system: { songes: { value: Number(songesValue ?? 0), points: Number(songesPoints ?? 0), }, cauchemar: { value: Number(cauchemarValue ?? 0), points: Number(cauchemarPoints ?? 0), }, }, } } static #getDialogResources(actor) { const context = this.#getRollContext(actor) return { songesValue: Number(context.system.songes?.value ?? 0), songesPoints: Number(context.system.songes?.points ?? 0), cauchemarValue: Number(context.system.cauchemar?.value ?? 0), cauchemarPoints: Number(context.system.cauchemar?.points ?? 0), } } static #createSpentResource(extraDie) { if (!extraDie) return null return { type: extraDie, amount: 1, label: game.i18n.localize(`LESOUBLIES.rolls.extraDice.${extraDie}`), } } static #canSpendResource(actor, resourceType) { return Number(actor?.system?.[resourceType]?.points ?? 0) >= 1 } static async #spendResource(actor, spentResource) { if (!actor?.update || !spentResource) return const path = `system.${spentResource.type}.points` const current = Number(actor.system?.[spentResource.type]?.points ?? 0) await actor.update({ [path]: Math.max(current - spentResource.amount, 0) }) } static #getWeaponAttackMode(weapon) { const category = String(weapon?.system?.category || "").toLowerCase() if (["distance", "ranged", "tir", "projectile", "jet"].some((keyword) => category.includes(keyword))) return "ranged" return "melee" } static #getAttackSkillOptions(mode) { if (mode === "ranged") { return [{ value: "tir", label: this.#getSkillLabel("tir") }] } return [ { value: "melee", label: this.#getSkillLabel("melee") }, { value: "corpsacorps", label: this.#getSkillLabel("corpsacorps") }, ] } static #getAttackReactionOptions(attackerSkill) { if (attackerSkill === "tir") { return [ { value: "esquive", label: "Esquive" }, { value: "melee", label: "Mêlée (avec bouclier)" }, ] } return [ { value: "corpsacorps", label: "Corps à corps" }, { value: "melee", label: "Mêlée" }, { value: "esquive", label: "Esquive" }, ] } static #getConfrontationTargetOptions(actor, selectedActor = null) { const choices = LesOubliesUtility.sortByName( game.actors.filter((candidate) => ( ["creature", "personnage"].includes(candidate.type) && candidate.id !== actor?.id )), ).map((candidate) => ({ value: candidate.id, label: `${candidate.name} — ${game.i18n.localize(`TYPES.Actor.${candidate.type}`)}`, })) return [ { value: "", label: "Saisie manuelle" }, ...LesOubliesUtility.ensureChoice( choices, selectedActor?.id, selectedActor ? `${selectedActor.name} — ${game.i18n.localize(`TYPES.Actor.${selectedActor.type}`)}` : null, ), ] } static #getConfrontationSkillOptions() { const skills = CONFIG.LESOUBLIES?.config?.skills ?? CONFIG.LESOUBLIES?.skills ?? {} return Object.entries(skills) .map(([value, data]) => ({ value, label: data.label ?? value, })) .sort((left, right) => left.label.localeCompare(right.label, "fr")) } static #resolveDialogTargetActor(actorId, fallbackTargetActor = null) { if (actorId !== undefined && actorId !== null && actorId !== "") { return game.actors.get(String(actorId)) ?? null } if (actorId === "") return null return fallbackTargetActor ?? null } static #getConfrontationTargetStatus(targetActor = null, { requireTarget = false } = {}) { if (!targetActor) { return { message: requireTarget ? "Aucune cible n'est actuellement sélectionnée. Sélectionnez un adversaire dans la liste ci-dessous pour lancer l'attaque." : "Aucune cible n'est actuellement sélectionnée. Choisissez un adversaire dans la liste ci-dessous ou conservez la saisie manuelle.", state: "empty", } } return { message: `Adversaire sélectionné : ${targetActor.name}. Ses valeurs de confrontation sont utilisées automatiquement.`, state: "selected", } } static #bindConfrontationTargetSelection(dialog, { actor, fallbackTargetActor = null, skillFieldName = null, skillKey = null, requireTarget = false, } = {}) { const root = this.#getDialogElement(dialog) const form = root?.querySelector("form") if (!form) return const actorField = form.elements.namedItem("defenderActorId") if (!(actorField instanceof HTMLSelectElement)) return const labelField = form.elements.namedItem("defenderLabel") const scoreField = form.elements.namedItem("defenderScore") const rollModeField = form.elements.namedItem("defenderRollMode") const songesValueField = form.elements.namedItem("defenderSongesValue") const songesPointsField = form.elements.namedItem("defenderSongesPoints") const cauchemarValueField = form.elements.namedItem("defenderCauchemarValue") const cauchemarPointsField = form.elements.namedItem("defenderCauchemarPoints") const skillField = skillFieldName ? form.elements.namedItem(skillFieldName) : null const targetStatusField = root.querySelector("[data-target-status]") const defaultLabel = game.i18n.localize("LESOUBLIES.rolls.defender") const getSelectedSkill = () => { if (skillKey) return skillKey if (skillField instanceof HTMLSelectElement) return String(skillField.value || "melee") return "melee" } const updateTargetFields = ({ preserveRollMode = false } = {}) => { const targetActor = this.#resolveDialogTargetActor(actorField.value, fallbackTargetActor) const hasActor = Boolean(targetActor) const currentSkill = getSelectedSkill() if (targetStatusField instanceof HTMLElement) { const targetStatus = this.#getConfrontationTargetStatus(targetActor, { requireTarget }) targetStatusField.textContent = targetStatus.message targetStatusField.dataset.state = targetStatus.state } if (labelField instanceof HTMLInputElement) { labelField.value = hasActor ? targetActor.name : (labelField.value || defaultLabel) labelField.readOnly = hasActor } if (scoreField instanceof HTMLInputElement) { if (hasActor) { scoreField.value = String(this.#getSkillScoreWithAlternatives(targetActor, currentSkill)) } scoreField.readOnly = hasActor } if (rollModeField instanceof HTMLSelectElement && hasActor && !preserveRollMode) { rollModeField.value = this.getDefaultRollMode(targetActor) } const resourceValues = hasActor ? { songesValue: Number(targetActor.system.songes?.value ?? 0), songesPoints: Number(targetActor.system.songes?.points ?? 0), cauchemarValue: Number(targetActor.system.cauchemar?.value ?? 0), cauchemarPoints: Number(targetActor.system.cauchemar?.points ?? 0), } : null const bindNumericField = (field, value) => { if (!(field instanceof HTMLInputElement)) return if (resourceValues) field.value = String(value) field.readOnly = hasActor } bindNumericField(songesValueField, resourceValues?.songesValue ?? 0) bindNumericField(songesPointsField, resourceValues?.songesPoints ?? 0) bindNumericField(cauchemarValueField, resourceValues?.cauchemarValue ?? 0) bindNumericField(cauchemarPointsField, resourceValues?.cauchemarPoints ?? 0) } actorField.addEventListener("change", () => updateTargetFields()) if (skillField instanceof HTMLSelectElement) { skillField.addEventListener("change", () => updateTargetFields({ preserveRollMode: true })) } updateTargetFields() } static async #withActorLock(lockKey, callback) { const previous = this.#actorLocks.get(lockKey) ?? Promise.resolve() let release const current = new Promise((resolve) => { release = resolve }) const queued = previous.finally(() => current) this.#actorLocks.set(lockKey, queued) await previous try { return await callback() } finally { release() if (this.#actorLocks.get(lockKey) === queued) { this.#actorLocks.delete(lockKey) } } } static #getModifierOptions(type, actionType) { const source = type === "prime" ? PRIME_DEFINITIONS : PENALTY_DEFINITIONS return source .filter((modifier) => modifier.actionTypes.includes("all") || modifier.actionTypes.includes(actionType)) .map((modifier) => ({ value: modifier.id, label: modifier.label, })) } static #resolveModifierSelection(primeId = "none", penaltyId = "none", actionType = "all") { const selectedPrime = PRIME_DEFINITIONS.find((modifier) => modifier.id === primeId && (modifier.actionTypes.includes("all") || modifier.actionTypes.includes(actionType))) ?? PRIME_DEFINITIONS[0] const selectedPenalty = PENALTY_DEFINITIONS.find((modifier) => modifier.id === penaltyId && (modifier.actionTypes.includes("all") || modifier.actionTypes.includes(actionType))) ?? PENALTY_DEFINITIONS[0] const modifiers = [selectedPrime, selectedPenalty] const summary = modifiers.reduce((accumulator, modifier) => { const effects = modifier.effects ?? {} accumulator.finalModifier += Number(effects.finalModifier ?? 0) accumulator.opponentFinalModifier += Number(effects.opponentFinalModifier ?? 0) accumulator.initiativeDelta += Number(effects.initiativeDelta ?? 0) accumulator.damageMultiplier *= Number(effects.damageMultiplier ?? 1) if (effects.armorDivisor) { accumulator.armorDivisor = accumulator.armorDivisor ? Math.max(accumulator.armorDivisor, effects.armorDivisor) : effects.armorDivisor } accumulator.nonLethal = accumulator.nonLethal || Boolean(effects.nonLethal) accumulator.targetsMultiple = accumulator.targetsMultiple || Boolean(effects.targetsMultiple) accumulator.riskIncident = accumulator.riskIncident || Boolean(effects.riskIncident) if (effects.note) accumulator.notes.push(effects.note) if (effects.nextReactionModifier) accumulator.nextReactionModifier += Number(effects.nextReactionModifier) return accumulator }, { finalModifier: 0, opponentFinalModifier: 0, initiativeDelta: 0, damageMultiplier: 1, armorDivisor: null, nonLethal: false, targetsMultiple: false, riskIncident: false, nextReactionModifier: 0, notes: [], }) return { prime: selectedPrime.id === "none" ? null : selectedPrime, penalty: selectedPenalty.id === "none" ? null : selectedPenalty, summary, labels: [selectedPrime, selectedPenalty] .filter((modifier) => modifier.id !== "none") .map((modifier) => modifier.label), } } static #getSkillLabel(skillKey) { return CONFIG.LESOUBLIES?.config?.skills?.[skillKey]?.label ?? CONFIG.LESOUBLIES?.skills?.[skillKey]?.label ?? skillKey } static #getSkillScoreWithAlternatives(actor, primaryKey, alternativeKeys = []) { if (!actor?.getSkillScoreByKey) return 0 const keys = [primaryKey, ...(alternativeKeys ?? [])].filter(Boolean) for (const key of keys) { const value = Number(actor.getSkillScoreByKey(key) ?? 0) if (value > 0) return value } return Number(actor.getSkillScoreByKey(primaryKey) ?? 0) } static #getWeaponBaseDamage(actor, weapon) { const damageText = String(weapon?.system?.damage || "") const parsed = this.#extractFirstInteger(damageText) if (parsed !== null) return parsed const explicitValue = Number(weapon?.system?.sizeValue ?? 0) if (explicitValue > 0) return explicitValue const actorSize = Number(actor?.system?.size?.value ?? 0) const sizeModifier = Number(weapon?.system?.sizeModifier ?? 0) return Math.max(actorSize + sizeModifier, 0) } static #extractFirstInteger(text) { const match = String(text || "").match(/-?\d+/) return match ? Number(match[0]) : null } static #getActorProtection(actor) { if (!actor) return 0 const armors = actor.itemTypes?.armure ?? actor.items?.filter?.((item) => item.type === "armure") ?? [] const equippedArmorProtection = armors .filter((item) => item.system?.equipped) .reduce((total, item) => total + Number(item.system?.protection ?? 0), 0) return Math.max(equippedArmorProtection, Number(actor.system?.protection ?? 0), 0) } static #computeDamageResolution({ actor, weapon = null, baseDamage = 0, baseLabel = "", targetProtection = 0, targetLabel = "", targetActor = null, applyToTarget = false, modifiers, }) { const summary = modifiers?.summary ?? {} const adjustedBase = Math.max(Math.ceil(Number(baseDamage ?? 0) * Number(summary.damageMultiplier ?? 1)), 0) const effectiveProtection = summary.armorDivisor ? Math.ceil(Number(targetProtection ?? 0) / Number(summary.armorDivisor)) : Number(targetProtection ?? 0) const finalDamage = Math.max(adjustedBase - effectiveProtection, 0) const applyResult = applyToTarget && targetActor ? { actor: targetActor, damage: finalDamage } : null return { weaponName: weapon?.name ?? "Attaque", baseDamage: Number(baseDamage ?? 0), baseLabel, adjustedBase, targetProtection: Number(targetProtection ?? 0), effectiveProtection, finalDamage, nonLethal: Boolean(summary.nonLethal), targetsMultiple: Boolean(summary.targetsMultiple), applyResult, targetLabel, } } static async #applyDamageToActor(actor, damage) { if (!actor?.update || !Number.isFinite(damage) || damage <= 0) return const currentHp = Number(actor.system?.hp?.value ?? 0) await actor.update({ "system.hp.value": Math.max(currentHp - damage, 0), }) } static #formatDifficultyBreakdown(presetValue, customValue) { const parts = [] if (presetValue) parts.push(`${presetValue > 0 ? "+" : ""}${presetValue}`) if (customValue) parts.push(`${customValue > 0 ? "+" : ""}${customValue}`) return parts.length ? parts.join(" ") : "0" } static #getDifficultyOptions(options, selectedValue = 0) { const normalizedValue = Number(selectedValue ?? 0) const entries = options.map((entry) => ({ value: Number(entry.value ?? 0), label: entry.label, })) if (!entries.some((entry) => entry.value === normalizedValue)) { entries.push({ value: normalizedValue, label: `Personnalisée (${normalizedValue > 0 ? "+" : ""}${normalizedValue})`, }) } return entries } static #getConfrontationOutcome(attacker, defender) { const attackerSuccess = attacker.success const defenderSuccess = defender.success if (attackerSuccess && !defenderSuccess) return "attacker-success" if (!attackerSuccess && defenderSuccess) return "defender-success" if (attacker.final > defender.final) return "attacker-advantage" if (defender.final > attacker.final) return "defender-advantage" return "tie" } static #buildPresetOutcome(actionKey, data) { switch (actionKey) { case "encourager": return { label: "Effet choisi", description: { initiative: "+4 à l'initiative à la fin du tour de l'allié.", action: "+3 au résultat final de sa prochaine action.", reaction: "+3 au résultat final de sa prochaine réaction.", }[data.outcomeChoice] ?? "Effet à préciser en jeu.", } case "intimider": return { label: "Effet choisi", description: { initiative: "-4 à l'initiative à la fin du tour de l'adversaire.", action: "-3 au résultat final de sa prochaine action.", reaction: "-3 au résultat final de sa prochaine réaction.", }[data.outcomeChoice] ?? "Effet à préciser en jeu.", } case "evaluer": return { label: "Paramètre observé", description: data.targetLabel?.trim() || "Paramètre non précisé.", } case "maitriser": return { label: "Suite envisagée", description: { silence: "Réduire au silence (gratuite, automatique).", otage: "Prendre en otage (gratuite, automatique ; couverture 4).", conforter: "Conforter sa prise (+3 au résultat initial, une fois).", etouffer: "Étouffer (libre, automatique ; 2 PV, peut être non létal).", attacher: "Attacher l'otage (libre, automatique).", }[data.outcomeChoice] ?? "Aucune suite immédiate déclarée.", } case "seDeplacer": return { label: "Destination", description: data.targetLabel?.trim() || "Destination non précisée.", } default: return null } } static #getTargetActor() { const target = Array.from(game.user?.targets ?? []).find((token) => token?.actor) return target?.actor ?? null } static #actorMatchesSpellGrant(actor, spell) { const métier = actor?.getCreationItem?.("metier") ?? null const grants = métier?.system?.spellGrants ?? [] return grants.some((grant) => ( String(grant.tradition || "").trim().toLowerCase() === String(spell.system.tradition || "").trim().toLowerCase() && String(grant.polarity || "").trim().toLowerCase() === String(spell.system.polarity || "").trim().toLowerCase() && String(grant.skillKey || "").trim().toLowerCase() === String(spell.system.skillKey || "").trim().toLowerCase() )) } }