1752 lines
64 KiB
JavaScript
1752 lines
64 KiB
JavaScript
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 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 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
|
|
return this.#createConfrontationMessage(actor, data, 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 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,
|
|
applyToTarget: Boolean(data.applyToTarget && targetActor),
|
|
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 || targetActor?.name || game.i18n.localize("LESOUBLIES.rolls.defender")),
|
|
targetActor,
|
|
applyToTarget: Boolean(data.applyToTarget && targetActor),
|
|
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: targetActor
|
|
? this.#getSkillScoreWithAlternatives(targetActor, data.defenderSkill)
|
|
: Number(data.defenderScore ?? 0),
|
|
defenderDifficulty: Number(data.defenderDifficulty ?? 0),
|
|
defenderRollMode: data.defenderRollMode,
|
|
defenderExtraDie: data.defenderExtraDie,
|
|
defenderFinalModifier: modifiers.summary.opponentFinalModifier,
|
|
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),
|
|
}, 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 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 && data.applyMetierSurcharge
|
|
const effectiveCost = Number(data.actualCost ?? 0) * (surcharge ? 2 : 1)
|
|
const paymentMode = String(data.paymentMode || "points")
|
|
if (paymentMode === "points") {
|
|
const resource = spell.system.polarity || "songes"
|
|
if (Number(actor.system?.[resource]?.points ?? 0) < effectiveCost) {
|
|
ui.notifications.warn(game.i18n.format("LESOUBLIES.rolls.notEnoughResource", {
|
|
resource: resource === "songes" ? game.i18n.localize("LESOUBLIES.ui.songes") : game.i18n.localize("LESOUBLIES.ui.cauchemar"),
|
|
actor: actor.name,
|
|
}))
|
|
return null
|
|
}
|
|
if (effectiveCost > 0) {
|
|
await actor.update({
|
|
[`system.${resource}.points`]: Math.max(Number(actor.system?.[resource]?.points ?? 0) - effectiveCost, 0),
|
|
})
|
|
}
|
|
}
|
|
|
|
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,
|
|
actualCost: Number(data.actualCost ?? 0),
|
|
effectiveCost,
|
|
costLabel: paymentMode === "points"
|
|
? `${effectiveCost} point${effectiveCost > 1 ? "s" : ""} de ${spell.system.polarity === "cauchemar" ? "Cauchemar" : "Songes"}`
|
|
: `${effectiveCost} fil${effectiveCost > 1 ? "s" : ""} de ${spell.system.polarity === "cauchemar" ? "Cauchemar" : "Songes"}`,
|
|
métierMatch,
|
|
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 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: data.defenderLabel,
|
|
targetActor,
|
|
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: data.defenderLabel,
|
|
defenderScore: targetActor
|
|
? this.#getSkillScoreWithAlternatives(targetActor, preset.defenderSkillKey)
|
|
: Number(data.defenderScore ?? 0),
|
|
defenderDifficulty: Number(data.defenderDifficulty ?? 0),
|
|
defenderRollMode: data.defenderRollMode,
|
|
defenderExtraDie: data.defenderExtraDie,
|
|
defenderFinalModifier: modifiers.summary.opponentFinalModifier,
|
|
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),
|
|
}, 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 content = await foundry.applications.handlebars.renderTemplate(
|
|
"systems/fvtt-les-oublies/templates/dialog-roll-test.hbs",
|
|
{
|
|
actor,
|
|
rollModes: this.getRollModes(),
|
|
extraDieModes: this.getExtraDieModes(),
|
|
resources: this.#getDialogResources(actor),
|
|
values: {
|
|
label: preset.label ?? "",
|
|
score: Number(preset.score ?? 0),
|
|
difficulty: Number(preset.difficulty ?? 0),
|
|
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 content = await foundry.applications.handlebars.renderTemplate(
|
|
"systems/fvtt-les-oublies/templates/dialog-roll-confrontation.hbs",
|
|
{
|
|
actor,
|
|
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"),
|
|
defenderScore: 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,
|
|
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 || ""),
|
|
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.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,
|
|
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"),
|
|
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,
|
|
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 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 || ""),
|
|
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 content = await foundry.applications.handlebars.renderTemplate(
|
|
"systems/fvtt-les-oublies/templates/dialog-spell-activation.hbs",
|
|
{
|
|
actor,
|
|
spell,
|
|
resources: this.#getDialogResources(actor),
|
|
isMetierMatch: this.#actorMatchesSpellGrant(actor, spell),
|
|
values: {
|
|
actualCost: Number(spell.system.cost ?? 0),
|
|
paymentMode: "points",
|
|
applyMetierSurcharge: true,
|
|
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"),
|
|
applyMetierSurcharge: data.applyMetierSurcharge === "on",
|
|
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,
|
|
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"),
|
|
defenderDifficulty: 0,
|
|
attackerRollMode: this.getDefaultRollMode(actor),
|
|
attackerExtraDie: "",
|
|
defenderRollMode: this.getDefaultRollMode(targetActor ?? actor),
|
|
defenderExtraDie: "",
|
|
defenderScore: 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,
|
|
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),
|
|
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"].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 #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 #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()
|
|
))
|
|
}
|
|
}
|