Files
fvtt-les-oublies/modules/les-oublies-rolls.js
T
2026-05-02 09:16:24 +02:00

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()
))
}
}