sotc-clash-arrow : flèches de clash entre combattants

This commit is contained in:
Yasser Boulayoune 2026-05-01 10:12:00 +02:00
commit ad63cafdc6
10 changed files with 2414 additions and 0 deletions

View file

@ -0,0 +1,168 @@
/**
* sotc-damage-wizard | ArrowBridge.js
*
* Pont avec sotc-clash-arrow pour lire les flèches et détecter le clash mutuel.
*
* IMPORTANT : sotc-clash-arrow expose son ArrowStore via :
* globalThis.sotcClashArrow.ArrowStore
* et NON via game.modules.get(...).api
*
* Structure d'une flèche ArrowStore :
* {
* id : string "tokenId:dieIndex→tokenId:dieIndex"
* fromToken : string tokenId source
* fromDie : number index du dé source (ordre SIO = tri initiative desc)
* toToken : string tokenId cible
* toDie : number index du dé cible
* mutual : boolean true si clash mutuel
* used : boolean true si dé passé en Used
* }
*/
const SBSA_MODULE = "sotc-battle-show-ability";
const CA_MODULE = "sotc-clash-arrow";
/* ------------------------------------------------------------------ */
/* Accès à ArrowStore */
/* ------------------------------------------------------------------ */
/**
* Retourne l'instance ArrowStore de sotc-clash-arrow, ou null si inactif.
* sotc-clash-arrow expose son store sur globalThis.sotcClashArrow.ArrowStore
*/
function getArrowStore() {
if (!game.modules.get(CA_MODULE)?.active) return null;
return globalThis.sotcClashArrow?.ArrowStore ?? null;
}
/* ------------------------------------------------------------------ */
/* Index SIO d'un combatant */
/* ------------------------------------------------------------------ */
/**
* Calcule le sioMarkerIndex d'un combatant.
* Identique à la logique de SIOBridge.js de clash-arrow :
* tri par initiative décroissante — marker[0] = initiative la plus haute.
* @param {Combatant} combatant
* @returns {number}
*/
function getSIOIndexForCombatant(combatant) {
if (!game.combat) return 0;
const sorted = game.combat.combatants
.filter(c => c.tokenId === combatant.tokenId)
.sort((a, b) => (b.initiative ?? -Infinity) - (a.initiative ?? -Infinity));
const idx = sorted.findIndex(c => c.id === combatant.id);
return idx === -1 ? 0 : idx;
}
/**
* Retourne le combatant correspondant à (tokenId, dieIndex).
* @param {string} tokenId
* @param {number} dieIndex
* @returns {Combatant|null}
*/
function getCombatantForDie(tokenId, dieIndex) {
if (!game.combat) return null;
const sorted = game.combat.combatants
.filter(c => c.tokenId === tokenId)
.sort((a, b) => (b.initiative ?? -Infinity) - (a.initiative ?? -Infinity));
return sorted[dieIndex] ?? null;
}
/* ------------------------------------------------------------------ */
/* API publique */
/* ------------------------------------------------------------------ */
/**
* Retourne la cible et le mode clash pour un combatant attaquant.
*
* Priorité :
* 1. ArrowStore (sotc-clash-arrow actif) — source de vérité
* 2. game.user.targets — fallback FoundryVTT natif
*
* @param {Combatant} attackerCombatant
* @returns {{ defenderCombatant: Combatant|null, isClash: boolean }}
*/
export function getTargetInfo(attackerCombatant) {
const arrowStore = getArrowStore();
if (arrowStore) {
const result = _getTargetFromArrowStore(arrowStore, attackerCombatant);
console.log("sotc-damage-wizard | ArrowBridge (ArrowStore) →", result);
return result;
}
const result = _getTargetFromFoundry(attackerCombatant);
console.log("sotc-damage-wizard | ArrowBridge (targets fallback) →", result);
return result;
}
/* ------------------------------------------------------------------ */
/* Logique ArrowStore */
/* ------------------------------------------------------------------ */
function _getTargetFromArrowStore(arrowStore, attackerCombatant) {
const tokenId = attackerCombatant.tokenId;
const dieIndex = getSIOIndexForCombatant(attackerCombatant);
const arrows = arrowStore.all();
console.log(
`sotc-damage-wizard | ArrowStore: ${arrows.length} fleche(s). Cherche fromToken=${tokenId} fromDie=${dieIndex}`
);
// Trouver la flèche sortante de ce dé précis
const outgoing = arrows.find(
a => a.fromToken === tokenId && a.fromDie === dieIndex
);
if (!outgoing) {
console.log("sotc-damage-wizard | Aucune fleche sortante trouvee.");
return { defenderCombatant: null, isClash: false };
}
console.log("sotc-damage-wizard | Fleche trouvee ->", outgoing);
const defenderCombatant = getCombatantForDie(outgoing.toToken, outgoing.toDie);
if (!defenderCombatant) {
console.log("sotc-damage-wizard | Combatant defenseur introuvable pour", outgoing.toToken, outgoing.toDie);
return { defenderCombatant: null, isClash: false };
}
// Clash mutuel : flèche marquée mutual OU flèche inverse présente
const reverseExists = arrows.some(
a => a.fromToken === outgoing.toToken
&& a.fromDie === outgoing.toDie
&& a.toToken === tokenId
&& a.toDie === dieIndex
);
const isClash = outgoing.mutual || reverseExists;
return { defenderCombatant, isClash };
}
/* ------------------------------------------------------------------ */
/* Fallback game.user.targets */
/* ------------------------------------------------------------------ */
function _getTargetFromFoundry(attackerCombatant) {
const target = game.user.targets.first();
if (!target) {
return { defenderCombatant: null, isClash: false };
}
// Premier combatant du token ciblé (tri initiative desc)
const defenderCombatant = game.combat?.combatants
.filter(c => c.tokenId === target.document.id)
.sort((a, b) => (b.initiative ?? -Infinity) - (a.initiative ?? -Infinity))[0] ?? null;
if (!defenderCombatant) {
return { defenderCombatant: null, isClash: false };
}
// Clash si le défenseur a dieState === 3 (compétence assignée)
const defDieState = defenderCombatant.getFlag(SBSA_MODULE, "dieState") ?? 0;
const isClash = defDieState === 3;
return { defenderCombatant, isClash };
}

View file

@ -0,0 +1,798 @@
/**
* sotc-damage-wizard | DamageEngine.js
*
* Moteur de résolution des clashes selon les règles officielles SotC.
*
* ──────────────────────────────────────────────────────────────────────
* RÈGLES DE CLASH (séquence linéaire)
* ──────────────────────────────────────────────────────────────────────
*
* Les dés clashent dans l'ordre : dé[0] vs dé[0], dé[1] vs dé[1], etc.
* Les dés restants (sans opposant) sont "sans opposition" (Unopposed).
* Après un clash, les DEUX dés sont détruits sauf exception.
*
* Offensive (Slash/Pierce/Blunt) :
* WIN ou UNOPPOSED → HP damage = Power, Stagger damage = Power
* TIE → Rien
* LOSE → Rien
*
* Défensive – Block :
* WIN → Stagger = Power_block - Power_opposing
* TIE → Rien
* LOSE vs Off → Réduit HP damage et Stagger pris = Power_block
* UNOPPOSED → Saved Defense Die (état 2)
*
* Défensive – Evade :
* WIN vs Off → Recycle ce dé (réutilisé), aucun autre effet WIN
* WIN vs Def → Regain Stagger Resist = Power_evade - Power_opposing
* TIE → Rien
* LOSE vs Off → Réduit Stagger pris = Power_evade
* UNOPPOSED → Saved Defense Die (état 2), sauf si recyclé
*
* Counter Dice (counter-slash/pierce/blunt/block/evade) :
* - Ne se déclenchent PAS si Unopposed
* - Si WIN → réutilisé contre le prochain dé adverse
* - Détruits à la fin de la compétence adverse
* → Donnent état 2 (Saved Defense Die) sur le Dé de Vitesse
*
* PE (Points d'Émotion) :
* Chaque clash (entre deux dés, peu importe le résultat) donne +1 PE
* aux DEUX combatants.
*
* ──────────────────────────────────────────────────────────────────────
*/
/* ------------------------------------------------------------------ */
/* Constantes */
/* ------------------------------------------------------------------ */
export const OFFENSIVE_TYPES = new Set([
"slash", "pierce", "blunt"
]);
export const COUNTER_OFFENSIVE_TYPES = new Set([
"counter-slash", "counter-pierce", "counter-blunt"
]);
export const BLOCK_TYPES = new Set([
"block"
]);
export const COUNTER_BLOCK_TYPES = new Set([
"counter-block"
]);
export const EVADE_TYPES = new Set([
"evade"
]);
export const COUNTER_EVADE_TYPES = new Set([
"counter-evade"
]);
// Tous les types offensifs (normaux + counter)
export const ALL_OFFENSIVE = new Set([
...OFFENSIVE_TYPES, ...COUNTER_OFFENSIVE_TYPES
]);
// Tous les types défensifs (normaux + counter)
export const ALL_DEFENSIVE = new Set([
...BLOCK_TYPES, ...COUNTER_BLOCK_TYPES,
...EVADE_TYPES, ...COUNTER_EVADE_TYPES
]);
// Tous les types Counter
export const ALL_COUNTER = new Set([
...COUNTER_OFFENSIVE_TYPES, ...COUNTER_BLOCK_TYPES, ...COUNTER_EVADE_TYPES
]);
/* ------------------------------------------------------------------ */
/* Helpers de typage */
/* ------------------------------------------------------------------ */
export function isOffensive(type) { return ALL_OFFENSIVE.has(type); }
export function isDefensive(type) { return ALL_DEFENSIVE.has(type); }
export function isCounter(type) { return ALL_COUNTER.has(type); }
export function isBlock(type) { return BLOCK_TYPES.has(type) || COUNTER_BLOCK_TYPES.has(type); }
export function isEvade(type) { return EVADE_TYPES.has(type) || COUNTER_EVADE_TYPES.has(type); }
/* ------------------------------------------------------------------ */
/* Récupération de la compétence assignée */
/* ------------------------------------------------------------------ */
export function getAssignedSkill(combatant) {
const skillId = combatant?.getFlag("sotc-battle-show-ability", "assignedSkillId") ?? null;
if (!skillId) return null;
return combatant.actor?.items.get(skillId) ?? null;
}
export function getSkillDice(skill) {
const raw = skill.system.dice?.die ?? [];
const arr = Array.isArray(raw) ? raw : Object.values(raw);
return arr.map(d => ({
type: d.type ?? "slash",
formula: d.formula ?? "1d6",
mods: Array.isArray(d.mods)
? d.mods.filter(Boolean)
: Object.values(d.mods ?? {}).filter(Boolean)
}));
}
/* ------------------------------------------------------------------ */
/* Résolution d'un dé (roll + modificateurs de statuts) */
/* ------------------------------------------------------------------ */
export async function resolveDie(die, actor, extraMod = 0) {
const mods = actor.system.modifiers ?? {};
const match = die.formula.match(/^\s*(\d+)\s*d\s*(\d+)((?:\s*[+-]\s*\d+)*)\s*$/);
if (!match) {
const r = await new Roll("0").roll({ async: true });
return { roll: r, total: 0, formulaDisplay: `Formule invalide: ${die.formula}` };
}
const numDice = parseInt(match[1]);
const dieSize = parseInt(match[2]);
const modStr = (match[3] || "").replace(/\s+/g, "");
const baseMod = modStr.split(/(?=[+-])/).filter(s => s.length)
.reduce((sum, s) => sum + parseInt(s), 0);
let statusMod = mods.all_mod ?? 0;
if (isOffensive(die.type)) statusMod += mods.off_mod ?? 0;
if (isDefensive(die.type)) statusMod += mods.def_mod ?? 0;
switch (die.type) {
case "slash": case "counter-slash": statusMod += mods.slash_mod ?? 0; break;
case "pierce": case "counter-pierce": statusMod += mods.pierce_mod ?? 0; break;
case "blunt": case "counter-blunt": statusMod += mods.blunt_mod ?? 0; break;
case "block": case "counter-block": statusMod += mods.block_mod ?? 0; break;
case "evade": case "counter-evade": statusMod += mods.evade_mod ?? 0; break;
}
const totalMod = baseMod + extraMod + statusMod;
const formula = `${numDice}d${dieSize} + ${totalMod}`;
const roll = await new Roll(formula).roll({ async: true });
let display = `${numDice}d${dieSize}`;
if (baseMod !== 0) display += baseMod > 0 ? ` + ${baseMod}` : ` - ${Math.abs(baseMod)}`;
if (extraMod !== 0) display += extraMod > 0 ? ` + ${extraMod}` : ` - ${Math.abs(extraMod)}`;
if (statusMod !== 0) display += statusMod > 0 ? ` + ${statusMod}` : ` - ${Math.abs(statusMod)}`;
display += ` = ${roll.total}`;
return { roll, total: roll.total, formulaDisplay: display };
}
/* ------------------------------------------------------------------ */
/* Résolution des clashes (séquence linéaire) */
/* ------------------------------------------------------------------ */
/**
* Résout la séquence de clashes entre deux ensembles de dés rollés.
*
* Retourne un tableau de matchups, un résumé des effets, et les compteurs
* de PE gagnés.
*
* @param {Array} atkRolls [{die, total, roll, formulaDisplay}]
* @param {Array} defRolls [] si attaque unilatérale
* @param {Actor} defenderActor
* @param {Actor} attackerActor
* @returns {ClashResult}
*
* ClashResult :
* {
* matchups : DieMatchup[] — dé par dé
* atkHpDamage : number — HP infligés au défenseur
* atkStaggerDamage : number — Stagger infligé au défenseur
* defHpDamage : number — HP infligés à l'attaquant (clash)
* defStaggerDamage : number — Stagger infligé à l'attaquant (clash)
* defStaggerRegain : number — Stagger récupéré par le défenseur (evade win)
* atkStaggerRegain : number — Stagger récupéré par l'attaquant (evade win)
* atkPE : number — PE gagnés par l'attaquant
* defPE : number — PE gagnés par le défenseur
* hasCounter : boolean — true si des counter-dice sont présents
* atkSavedDice : boolean — l'attaquant a des dés à sauvegarder (état 2)
* defSavedDice : boolean — le défenseur a des dés à sauvegarder (état 2)
* }
*
* DieMatchup :
* {
* side : "atk" | "def"
* dieType : string
* power : number
* formulaDisplay : string
* oppType : string|null
* oppPower : number|null
* clashType : "clash" | "unopposed"
* verdict : string — voir constantes VERDICT_* ci-dessous
* effects : string[] — descriptions texte des effets
* }
*/
export function resolveClash(atkRolls, defRolls, defenderActor, attackerActor) {
// Affinités des acteurs
const defAff = _getAffinities(defenderActor);
const atkAff = attackerActor ? _getAffinities(attackerActor) : null;
const matchups = [];
// ── Compteurs d'effets ──────────────────────────────────────────────
let atkHpDamage = 0;
let atkStaggerDamage = 0;
let defHpDamage = 0;
let defStaggerDamage = 0;
let defStaggerRegain = 0;
let atkStaggerRegain = 0;
let atkPE = 0;
let defPE = 0;
let atkSavedDice = false;
let defSavedDice = false;
// ── Séquence linéaire côté attaquant ───────────────────────────────
// On travaille sur des copies mutables pour gérer les counter-dice recyclés
const defQueue = [...defRolls]; // dés du défenseur disponibles pour clash
// Compteur de clashes par dé défenseur (counter-dice : max 1 clash par dé adverse)
// Clé : index dans defRolls d'origine → nombre de fois utilisé
const defUsageCount = new Array(defRolls.length).fill(0);
// Index courant dans defQueue pour la séquence linéaire
let defIdx = 0;
for (let atkIdx = 0; atkIdx < atkRolls.length; atkIdx++) {
const atk = atkRolls[atkIdx];
const atkType = atk.die.type;
// Trouver l'opposant du défenseur à cet index (séquence linéaire)
const opp = defQueue[defIdx] ?? null;
const hasOpp = opp !== null;
let matchup;
if (!hasOpp) {
// ── UNOPPOSED côté attaquant ──
matchup = _resolveAtkUnopposed(atk, defAff, atkIdx);
atkHpDamage += matchup._hpDmg ?? 0;
atkStaggerDamage += matchup._staggerDmg ?? 0;
if (matchup._saved) atkSavedDice = true;
} else {
// ── CLASH ──
const oppType = opp.die.type;
// PE : chaque clash donne +1 aux deux
atkPE++;
defPE++;
matchup = _resolveAtkClash(atk, opp, defAff, atkAff);
atkHpDamage += matchup._hpDmg ?? 0;
atkStaggerDamage += matchup._staggerDmg ?? 0;
// Effets défensifs du dé opposant sur l'attaquant (block lose / evade lose)
defHpDamage += matchup._reverseHpDmg ?? 0;
defStaggerDamage += matchup._reverseStaggerDmg ?? 0;
defStaggerRegain += matchup._defStaggerRegain ?? 0;
atkStaggerRegain += matchup._atkStaggerRegain ?? 0;
// Avancer dans la file défenseur (sauf si counter recyclé)
if (!matchup._oppRecycled) {
defIdx++;
}
// Si le dé défenseur est un counter et perd → il est quand même consommé
if (matchup._oppConsumed) {
defIdx++;
}
}
matchups.push(matchup);
}
// ── Dés du défenseur restants (défenseur a plus de dés que l'attaquant) ──
for (let i = defIdx; i < defQueue.length; i++) {
const def = defQueue[i];
const matchup = _resolveDefUnopposed(def, atkAff, i);
defHpDamage += matchup._hpDmg ?? 0;
defStaggerDamage += matchup._staggerDmg ?? 0;
defStaggerRegain += matchup._defStaggerRegain ?? 0;
if (matchup._saved) defSavedDice = true;
matchups.push(matchup);
}
// Présence de counter-dice
const hasCounter = [...atkRolls, ...defRolls].some(r => isCounter(r.die.type));
return {
matchups,
atkHpDamage,
atkStaggerDamage,
defHpDamage,
defStaggerDamage,
defStaggerRegain,
atkStaggerRegain,
atkPE,
defPE,
hasCounter,
atkSavedDice,
defSavedDice,
};
}
/* ------------------------------------------------------------------ */
/* Résolution d'un dé attaquant sans opposition */
/* ------------------------------------------------------------------ */
function _resolveAtkUnopposed(atk, defAff, idx) {
const type = atk.die.type;
const power = atk.total;
const effects = [];
let _hpDmg = 0, _staggerDmg = 0, _saved = false;
if (isOffensive(type)) {
// Offensive Unopposed → deal damage = power
const aff = defAff[type.replace("counter-", "")] ?? { damage: 0, stagger: 0 };
_hpDmg = Math.max(0, power - aff.damage);
_staggerDmg = Math.max(0, power - aff.stagger);
effects.push(`HP -${_hpDmg}, Stagger -${_staggerDmg}`);
} else if (isBlock(type)) {
if (!isCounter(type)) {
// Block Unopposed → Saved Defense Die
_saved = true;
effects.push("Saved Defense Die");
} else {
// Counter-Block Unopposed → ne se déclenche pas, détruit
effects.push("Counter : pas de déclenchement sans opposition");
}
} else if (isEvade(type)) {
if (!isCounter(type)) {
// Evade Unopposed → Saved Defense Die (sauf si recyclé, géré ailleurs)
_saved = true;
effects.push("Saved Defense Die");
} else {
// Counter-Evade Unopposed → ne se déclenche pas, détruit
effects.push("Counter : pas de déclenchement sans opposition");
}
}
return {
side: "atk", dieType: type, power, formulaDisplay: atk.formulaDisplay,
oppType: null, oppPower: null,
clashType: "unopposed",
verdict: _getUnopposedVerdict(type),
effects,
_hpDmg, _staggerDmg, _saved,
_oppRecycled: false, _oppConsumed: false,
};
}
/* ------------------------------------------------------------------ */
/* Résolution d'un dé attaquant en clash */
/* ------------------------------------------------------------------ */
function _resolveAtkClash(atk, opp, defAff, atkAff) {
const atkType = atk.die.type;
const oppType = opp.die.type;
const atkPower = atk.total;
const oppPower = opp.total;
const effects = [];
let _hpDmg = 0, _staggerDmg = 0;
let _reverseHpDmg = 0, _reverseStaggerDmg = 0;
let _defStaggerRegain = 0, _atkStaggerRegain = 0;
let _oppRecycled = false, _oppConsumed = false;
let verdict;
// Résultat du clash
const result = atkPower > oppPower ? "win"
: atkPower < oppPower ? "lose"
: "tie";
// ── Attaquant offensif vs dé défenseur ──────────────────────────────
if (isOffensive(atkType)) {
if (isOffensive(oppType)) {
// Offensive vs Offensive — les deux clashent
verdict = result === "win" ? "off-win"
: result === "lose" ? "off-lose"
: "tie";
if (result === "win") {
// Attaquant gagne → deal damage
const aff = defAff[atkType.replace("counter-", "")] ?? { damage: 0, stagger: 0 };
_hpDmg = Math.max(0, atkPower - aff.damage);
_staggerDmg = Math.max(0, atkPower - aff.stagger);
effects.push(`WIN → HP -${_hpDmg}, Stagger -${_staggerDmg}`);
// Le dé défenseur offensif perd → rien de sa part
} else if (result === "lose") {
// Défenseur offensif gagne → il deal damage sur l'attaquant
if (atkAff) {
const aff = atkAff[oppType.replace("counter-", "")] ?? { damage: 0, stagger: 0 };
_reverseHpDmg = Math.max(0, oppPower - aff.damage);
_reverseStaggerDmg = Math.max(0, oppPower - aff.stagger);
effects.push(`LOSE → ennemi HP -${_reverseHpDmg}, ennemi Stagger -${_reverseStaggerDmg}`);
}
} else {
effects.push("TIE → Rien");
}
} else if (isBlock(oppType)) {
// Offensive vs Block
verdict = result === "win" ? "off-win-vs-block"
: result === "lose" ? "off-lose-vs-block"
: "tie";
if (result === "win") {
// Block perd → réduit dégâts de sa Power
const aff = defAff[atkType.replace("counter-", "")] ?? { damage: 0, stagger: 0 };
const rawHp = Math.max(0, atkPower - aff.damage);
const rawStg = Math.max(0, atkPower - aff.stagger);
// Block lose : réduit HP et Stagger pris de Power_block
const reduction = oppPower;
_hpDmg = Math.max(0, rawHp - reduction);
_staggerDmg = Math.max(0, rawStg - reduction);
effects.push(`WIN vs Block → HP -${_hpDmg} (réduit de ${reduction}), Stagger -${_staggerDmg}`);
// Block Lose → réduit damage taken = Power_block (déjà géré ci-dessus dans notre sens)
} else if (result === "lose") {
// Attaque gagne → dégâts plein
const aff = defAff[atkType.replace("counter-", "")] ?? { damage: 0, stagger: 0 };
_hpDmg = Math.max(0, atkPower - aff.damage);
_staggerDmg = Math.max(0, atkPower - aff.stagger);
effects.push(`LOSE vs Block → HP -${_hpDmg}, Stagger -${_staggerDmg}`);
// Block win → stagger on attacker = Power_block - Power_off
_reverseStaggerDmg = Math.max(0, oppPower - atkPower);
if (_reverseStaggerDmg > 0) effects.push(`Block WIN → ennemi Stagger -${_reverseStaggerDmg}`);
} else {
effects.push("TIE → Rien");
}
} else if (isEvade(oppType)) {
// Offensive vs Evade
verdict = result === "win" ? "off-win-vs-evade"
: result === "lose" ? "off-lose-vs-evade"
: "tie";
if (result === "win") {
// Evade perd → dégâts plein, Stagger réduit de Power_evade
const aff = defAff[atkType.replace("counter-", "")] ?? { damage: 0, stagger: 0 };
_hpDmg = Math.max(0, atkPower - aff.damage);
const rawStg = Math.max(0, atkPower - aff.stagger);
_staggerDmg = Math.max(0, rawStg - oppPower); // Evade Lose réduit Stagger
effects.push(`WIN vs Evade → HP -${_hpDmg}, Stagger -${_staggerDmg} (réduit de ${oppPower})`);
} else if (result === "lose") {
// Evade gagne vs Offensive → recycle le dé evade, aucun dégât
_oppRecycled = !isCounter(oppType); // Counter-Evade : ne se recycle pas vraiment
effects.push("LOSE vs Evade → Evade gagne, recyclé (aucun dégât)");
} else {
effects.push("TIE → Rien");
}
}
// ── Attaquant défensif vs dé défenseur ─────────────────────────────
} else if (isBlock(atkType)) {
if (isOffensive(oppType)) {
// Block vs Offensive
verdict = result === "win" ? "block-win-vs-off"
: result === "lose" ? "block-lose-vs-off"
: "tie";
if (result === "win") {
// Block Win → stagger sur le défenseur (opposant offensif)
_reverseStaggerDmg = Math.max(0, atkPower - oppPower);
effects.push(`Block WIN → ennemi Stagger -${_reverseStaggerDmg}`);
// Le dé offensif perd → dégâts réduits de Power_block
// (l'attaque adverse est annulée ici : on n'inflige rien)
} else if (result === "lose") {
// Block Lose vs Off → réduit HP damage et Stagger pris de Power_block
// L'offensif adverse inflige ses dégâts, réduits de notre Power
if (atkAff) {
const aff = atkAff[oppType.replace("counter-", "")] ?? { damage: 0, stagger: 0 };
const rawHp = Math.max(0, oppPower - aff.damage);
const rawStg = Math.max(0, oppPower - aff.stagger);
_reverseHpDmg = Math.max(0, rawHp - atkPower);
_reverseStaggerDmg = Math.max(0, rawStg - atkPower);
effects.push(`Block LOSE → dégâts réduits de ${atkPower} → HP -${_reverseHpDmg}, Stagger -${_reverseStaggerDmg}`);
}
} else {
effects.push("TIE → Rien");
}
} else {
// Block vs Block ou Block vs Evade → clash normal (Win = stagger diff)
verdict = result === "win" ? "block-win-vs-def" : result === "lose" ? "block-lose-vs-def" : "tie";
if (result === "win") {
_reverseStaggerDmg = Math.max(0, atkPower - oppPower);
effects.push(`Block WIN → ennemi Stagger -${_reverseStaggerDmg}`);
} else {
effects.push(result === "tie" ? "TIE → Rien" : "Block LOSE → Rien");
}
}
} else if (isEvade(atkType)) {
if (isOffensive(oppType)) {
// Evade vs Offensive
verdict = result === "win" ? "evade-win-vs-off"
: result === "lose" ? "evade-lose-vs-off"
: "tie";
if (result === "win") {
// Evade Win vs Off → Recycle ce dé, aucun autre effet WIN
// (le dé offensif adverse ne deal pas de dégâts)
effects.push("Evade WIN vs Off → Recyclé, aucun dégât");
// Note : _oppRecycled n'est pas l'attaquant ici mais le dé evade lui-même
// On note ça dans le matchup via le verdict
} else if (result === "lose") {
// Evade Lose vs Off → réduit Stagger pris de Power_evade
if (atkAff) {
const aff = atkAff[oppType.replace("counter-", "")] ?? { damage: 0, stagger: 0 };
const rawHp = Math.max(0, oppPower - aff.damage);
const rawStg = Math.max(0, oppPower - aff.stagger);
_reverseHpDmg = rawHp;
_reverseStaggerDmg = Math.max(0, rawStg - atkPower);
effects.push(`Evade LOSE → Stagger réduit de ${atkPower} → HP -${_reverseHpDmg}, Stagger -${_reverseStaggerDmg}`);
}
} else {
effects.push("TIE → Rien");
}
} else {
// Evade vs Defensive
verdict = result === "win" ? "evade-win-vs-def" : result === "lose" ? "evade-lose-vs-def" : "tie";
if (result === "win") {
// Evade Win vs Def → regain stagger resist = Power_evade - Power_def
_atkStaggerRegain = Math.max(0, atkPower - oppPower);
effects.push(`Evade WIN vs Def → Stagger regain +${_atkStaggerRegain}`);
} else {
effects.push(result === "tie" ? "TIE → Rien" : "Evade LOSE → Rien");
}
}
}
// Counter : après un clash perdu, le counter est consommé (pas recyclé)
if (isCounter(oppType) && result === "lose") {
_oppConsumed = true;
}
return {
side: "atk", dieType: atkType, power: atkPower, formulaDisplay: atk.formulaDisplay,
oppType, oppPower, oppFormulaDisplay: opp.formulaDisplay,
clashType: "clash",
verdict,
effects,
_hpDmg, _staggerDmg,
_reverseHpDmg, _reverseStaggerDmg,
_defStaggerRegain, _atkStaggerRegain,
_oppRecycled, _oppConsumed,
};
}
/* ------------------------------------------------------------------ */
/* Résolution d'un dé défenseur sans opposition */
/* ------------------------------------------------------------------ */
function _resolveDefUnopposed(def, atkAff, idx) {
const type = def.die.type;
const power = def.total;
const effects = [];
let _hpDmg = 0, _staggerDmg = 0, _defStaggerRegain = 0, _saved = false;
if (isOffensive(type)) {
// Offensif défenseur Unopposed → deal damage sur l'attaquant
const aff = atkAff?.[type.replace("counter-", "")] ?? { damage: 0, stagger: 0 };
_hpDmg = Math.max(0, power - aff.damage);
_staggerDmg = Math.max(0, power - aff.stagger);
effects.push(`HP -${_hpDmg}, Stagger -${_staggerDmg}`);
} else if (isBlock(type) || isEvade(type)) {
if (!isCounter(type)) {
_saved = true;
effects.push("Saved Defense Die");
} else {
effects.push("Counter : pas de déclenchement sans opposition");
}
}
return {
side: "def", dieType: type, power, formulaDisplay: def.formulaDisplay,
oppType: null, oppPower: null,
clashType: "unopposed",
verdict: _getUnopposedVerdict(type),
effects,
_hpDmg, _staggerDmg, _defStaggerRegain, _saved,
_oppRecycled: false, _oppConsumed: false,
};
}
/* ------------------------------------------------------------------ */
/* Verdict Unopposed */
/* ------------------------------------------------------------------ */
function _getUnopposedVerdict(type) {
if (isOffensive(type)) return "unopposed-off";
if ((isBlock(type) || isEvade(type)) && !isCounter(type)) return "unopposed-saved";
return "unopposed-counter-noop";
}
/* ------------------------------------------------------------------ */
/* Affinités d'un acteur */
/* ------------------------------------------------------------------ */
function _getAffinities(actor) {
const m = actor.system.modifiers ?? {};
return {
slash: { damage: m.slash_damage_affinity ?? 0, stagger: m.slash_stagger_affinity ?? 0 },
pierce: { damage: m.pierce_damage_affinity ?? 0, stagger: m.pierce_stagger_affinity ?? 0 },
blunt: { damage: m.blunt_damage_affinity ?? 0, stagger: m.blunt_stagger_affinity ?? 0 },
};
}
/* ------------------------------------------------------------------ */
/* Application des effets sur les acteurs */
/* ------------------------------------------------------------------ */
export async function applyClashResults(result, attackerActor, defenderActor, attackerCombatant, defenderCombatant) {
// Dégâts sur le défenseur
if (result.atkHpDamage > 0 || result.atkStaggerDamage > 0) {
await _applyDamage(defenderActor, result.atkHpDamage, result.atkStaggerDamage);
}
// Dégâts sur l'attaquant (clash)
if (result.defHpDamage > 0 || result.defStaggerDamage > 0) {
await _applyDamage(attackerActor, result.defHpDamage, result.defStaggerDamage);
}
// Regain Stagger du défenseur (Evade Win)
if (result.defStaggerRegain > 0) {
await _applyStaggerRegain(defenderActor, result.defStaggerRegain);
}
// Regain Stagger de l'attaquant (Evade Win vs Def)
if (result.atkStaggerRegain > 0) {
await _applyStaggerRegain(attackerActor, result.atkStaggerRegain);
}
// PE
if (result.atkPE > 0) await _applyPE(attackerActor, result.atkPE);
if (result.defPE > 0) await _applyPE(defenderActor, result.defPE);
// États des dés de vitesse
// Attaquant → toujours Used (état 1) après résolution
await markDieState(attackerCombatant, 1);
// Défenseur :
// - Si des dés sauvegardés → état 2 (Saved Defense Die)
// - Sinon → état 1 (Used)
if (defenderCombatant) {
const defState = result.defSavedDice ? 2 : 1;
await markDieState(defenderCombatant, defState);
}
// Counter dice côté attaquant → état 2 aussi
if (result.atkSavedDice) {
await markDieState(attackerCombatant, 2);
}
}
async function _applyDamage(actor, hpDmg, staggerDmg) {
const sys = actor.system;
let hp = (sys.health?.value ?? 0) - hpDmg;
let stg = (sys.stagger?.value ?? 0) - staggerDmg;
// Cascade stagger → HP si stagger < 0
if (stg < 0) {
hp -= Math.floor(Math.abs(stg) / 2);
stg = 0;
}
await actor.update({
"system.health.value": Math.max(0, hp),
"system.stagger.value": Math.max(0, stg),
});
}
async function _applyStaggerRegain(actor, amount) {
const sys = actor.system;
const curr = sys.stagger?.value ?? 0;
const max = sys.stagger?.max ?? curr;
await actor.update({ "system.stagger.value": Math.min(max, curr + amount) });
}
async function _applyPE(actor, amount) {
const curr = actor.system.emotion ?? 0;
await actor.update({ "system.emotion": curr + amount });
}
/* ------------------------------------------------------------------ */
/* Gestion de l'état d'un Dé de Vitesse */
/* ------------------------------------------------------------------ */
/**
* Passe un combatant à l'état donné dans SBSA + SIO, et désassigne la compétence.
* @param {Combatant} combatant
* @param {number} state 0=Unused, 1=Used, 2=Saved Defense Die, 3=Assigned
*/
export async function markDieState(combatant, state) {
if (!combatant) return;
// Désassigner la compétence (sauf si on passe en état 3)
if (state !== 3) {
await combatant.unsetFlag("sotc-battle-show-ability", "assignedSkillId");
}
await combatant.setFlag("sotc-battle-show-ability", "dieState", state);
await combatant.setFlag("sotc", "used", state !== 0);
// Synchroniser SIO
const tokenDoc = combatant.token;
if (tokenDoc && game.modules.get("stars-initiative-overlay")?.active) {
const sorted = game.combat.combatants
.filter(c => c.tokenId === combatant.tokenId)
.sort((a, b) => (b.initiative ?? -Infinity) - (a.initiative ?? -Infinity));
const dieIndex = sorted.findIndex(c => c.id === combatant.id);
if (dieIndex === -1) return;
const numDice = combatant.actor?.system?.speed_dice?.num_dice ?? 1;
const states = [...(tokenDoc.getFlag("stars-initiative-overlay", "diceStates") || [])];
while (states.length < numDice) states.push(0);
states[dieIndex] = state;
await tokenDoc.setFlag("stars-initiative-overlay", "diceStates", states);
}
}
// Alias conservé pour compatibilité avec main.js
export const markDieAsUsed = (combatant) => markDieState(combatant, 1);
/* ------------------------------------------------------------------ */
/* Chat message résumé */
/* ------------------------------------------------------------------ */
export async function sendResultToChat({ attackerName, defenderName, skillName, result }) {
const { matchups, atkHpDamage, atkStaggerDamage, defHpDamage, defStaggerDamage,
defStaggerRegain, atkStaggerRegain, atkPE, defPE } = result;
const matchupLines = matchups.map(m => {
const sideLabel = m.side === "atk" ? attackerName : defenderName;
const vs = m.clashType === "clash"
? `<span style="color:#4a4a7a;font-size:10px;"> VS </span>
<span class="sotc-dw-tag-${m.oppType}">${m.oppType} ${m.oppPower}</span>`
: `<span style="color:#4a4a6a;font-size:10px;"> (sans opposition)</span>`;
const icon = `systems/sotc/assets/dice types/${m.dieType}.png`;
return `
<div style="display:flex;align-items:center;gap:5px;margin-bottom:3px;font-size:11px;">
<img src="${icon}" style="width:18px;height:18px;border:none;flex-shrink:0;"/>
<strong>${m.dieType} ${m.power}</strong>${vs}
<span style="margin-left:auto;font-size:10px;color:#8888aa;">${m.effects.join(", ")}</span>
</div>`;
}).join("");
const defenderSection = (atkHpDamage > 0 || atkStaggerDamage > 0)
? `<p>🎯 <strong>${defenderName}</strong> → HP -${atkHpDamage}, Stagger -${atkStaggerDamage}</p>`
: `<p>🎯 <strong>${defenderName}</strong> → Aucun dégât</p>`;
const attackerSection = (defHpDamage > 0 || defStaggerDamage > 0)
? `<p>🎯 <strong>${attackerName}</strong> → HP -${defHpDamage}, Stagger -${defStaggerDamage}</p>`
: "";
const regainSection = (defStaggerRegain > 0 || atkStaggerRegain > 0)
? `<p>💚 Stagger regain : ${defStaggerRegain > 0 ? `${defenderName} +${defStaggerRegain} ` : ""}${atkStaggerRegain > 0 ? `${attackerName} +${atkStaggerRegain}` : ""}</p>`
: "";
const peSection = (atkPE > 0 || defPE > 0)
? `<p>💜 PE : ${attackerName} +${atkPE}, ${defenderName} +${defPE}</p>`
: "";
const content = `
<div class="sotc-dw-chat">
<h3>⚔ ${skillName ?? "?"} — ${attackerName} → ${defenderName}</h3>
<div style="margin-bottom:6px;">${matchupLines}</div>
<hr/>
${defenderSection}
${attackerSection}
${regainSection}
${peSection}
</div>`;
await ChatMessage.create({ speaker: ChatMessage.getSpeaker(), content });
}

View file

@ -0,0 +1,262 @@
/**
* sotc-damage-wizard | DamageWizardApp.js
*
* Fenêtre Application FoundryVTT gérant la résolution des dégâts.
* Utilise le moteur DamageEngine.resolveClash() qui implémente les
* règles officielles SotC (séquence linéaire, counter-dice, PE, etc.)
*/
import {
getAssignedSkill,
getSkillDice,
resolveDie,
resolveClash,
applyClashResults,
sendResultToChat,
} from "./DamageEngine.js";
/**
* Cache statique des résultats : persiste entre fermetures/réouvertures.
* Clé : attackerCombatantId
* Valeur : { atkRolls, defRolls, clashResult }
*/
export const _rollCache = new Map();
export class DamageWizardApp extends Application {
constructor(attackerCombatantId, defenderCombatantId, isClash = false, options = {}) {
super(options);
this.attackerCombatantId = attackerCombatantId;
this.defenderCombatantId = defenderCombatantId;
this.isClash = isClash;
// Restaurer depuis le cache si déjà rollé
const cached = _rollCache.get(attackerCombatantId);
this._atkRolls = cached?.atkRolls ?? null;
this._defRolls = cached?.defRolls ?? null;
this._clashResult = cached?.clashResult ?? null;
}
/* ------------------------------------------------------------------ */
/* defaultOptions */
/* ------------------------------------------------------------------ */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "sotc-dw-app",
template: "modules/sotc-damage-wizard/templates/damage-wizard.hbs",
classes: ["sotc-dw-window"],
width: 820,
height: "auto",
resizable: false,
popOut: true,
});
}
/* ------------------------------------------------------------------ */
/* Accesseurs */
/* ------------------------------------------------------------------ */
get attackerCombatant() { return game.combat?.combatants.get(this.attackerCombatantId) ?? null; }
get defenderCombatant() { return game.combat?.combatants.get(this.defenderCombatantId) ?? null; }
get attackerActor() { return this.attackerCombatant?.actor ?? null; }
get defenderActor() { return this.defenderCombatant?.actor ?? null; }
/* ------------------------------------------------------------------ */
/* _buildSideData */
/* ------------------------------------------------------------------ */
_buildSideData(combatant) {
if (!combatant) return null;
const actor = combatant.actor;
if (!actor) return null;
const sys = actor.system;
const skill = getAssignedSkill(combatant);
const dice = skill ? getSkillDice(skill) : [];
return {
combatantId: combatant.id,
actorId: actor.id,
name: combatant.name,
img: actor.img,
hpValue: sys.health?.value ?? 0,
hpMax: sys.health?.max ?? 1,
hpPercent: Math.round(((sys.health?.value ?? 0) / (sys.health?.max ?? 1)) * 100),
staggerValue: sys.stagger?.value ?? 0,
staggerMax: sys.stagger?.max ?? 1,
staggerPercent: Math.round(((sys.stagger?.value ?? 0) / (sys.stagger?.max ?? 1)) * 100),
lightValue: sys.light?.value ?? 0,
lightMax: sys.light?.max ?? 1,
emotion: sys.emotion ?? 0,
skillName: skill?.name ?? null,
skillImg: skill?.img ?? null,
dice,
};
}
/* ------------------------------------------------------------------ */
/* getData */
/* ------------------------------------------------------------------ */
async getData(options = {}) {
const attacker = this._buildSideData(this.attackerCombatant);
const defender = this._buildSideData(this.defenderCombatant);
const hasRolled = !!this._clashResult;
// ── Construire les lignes de matchup pour le template ──────────────
let matchupRows = null;
let summaryAtk = null;
let summaryDef = null;
let summaryPE = null;
if (this._clashResult) {
const r = this._clashResult;
matchupRows = r.matchups.map(m => ({
side: m.side,
dieType: m.dieType,
power: m.power,
formulaDisplay: m.formulaDisplay,
oppType: m.oppType,
oppPower: m.oppPower,
oppFormulaDisplay: m.oppFormulaDisplay ?? null,
clashType: m.clashType,
verdict: m.verdict,
effects: m.effects,
}));
// Résumé dégâts
summaryAtk = {
name: attacker?.name ?? "?",
hpDmg: r.atkHpDamage,
stgDmg: r.atkStaggerDamage,
stgGain: r.defStaggerRegain, // regain du défenseur (evade win)
};
summaryDef = {
name: attacker?.name ?? "?", // dégâts SUR l'attaquant
hpDmg: r.defHpDamage,
stgDmg: r.defStaggerDamage,
stgGain: r.atkStaggerRegain,
};
summaryPE = { atkPE: r.atkPE, defPE: r.defPE };
}
return {
isClash: this.isClash,
attacker,
defender,
hasRolled,
matchupRows,
summaryAtk,
summaryDef,
summaryPE,
clashResult: this._clashResult,
};
}
/* ------------------------------------------------------------------ */
/* activateListeners */
/* ------------------------------------------------------------------ */
activateListeners(html) {
super.activateListeners(html);
html.find(".dw-foot-roll").click(() => this._onRoll());
html.find(".dw-foot-apply").click(() => this._onApply());
html.find(".dw-foot-cancel").click(() => this.close());
}
/* ------------------------------------------------------------------ */
/* Roll */
/* ------------------------------------------------------------------ */
async _onRoll() {
if (this._clashResult) {
ui.notifications.warn(game.i18n.localize("SOTC_DW.AlreadyRolled"));
return;
}
const atkActor = this.attackerActor;
const defActor = this.defenderActor;
if (!atkActor || !defActor) return;
const atkSkill = getAssignedSkill(this.attackerCombatant);
if (!atkSkill) {
ui.notifications.warn(game.i18n.localize("SOTC_DW.NoSkill"));
return;
}
// ── Roll attaquant ─────────────────────────────────────────────────
const atkDice = getSkillDice(atkSkill);
this._atkRolls = [];
for (const die of atkDice) {
const res = await resolveDie(die, atkActor);
this._atkRolls.push({ die, ...res });
}
// ── Roll défenseur (clash uniquement) ──────────────────────────────
this._defRolls = [];
if (this.isClash) {
const defSkill = getAssignedSkill(this.defenderCombatant);
if (defSkill) {
for (const die of getSkillDice(defSkill)) {
const res = await resolveDie(die, defActor);
this._defRolls.push({ die, ...res });
}
}
}
// ── Résolution des clashes ─────────────────────────────────────────
this._clashResult = resolveClash(this._atkRolls, this._defRolls, defActor, atkActor);
// Persister dans le cache
_rollCache.set(this.attackerCombatantId, {
atkRolls: this._atkRolls,
defRolls: this._defRolls,
clashResult: this._clashResult,
});
this.render(false);
}
/* ------------------------------------------------------------------ */
/* Apply */
/* ------------------------------------------------------------------ */
async _onApply() {
if (!this._clashResult) {
ui.notifications.warn(game.i18n.localize("SOTC_DW.RollFirst"));
return;
}
const atkActor = this.attackerActor;
const defActor = this.defenderActor;
if (!atkActor || !defActor) return;
// Appliquer tous les effets (dégâts, PE, états des dés)
await applyClashResults(
this._clashResult,
atkActor,
defActor,
this.attackerCombatant,
this.defenderCombatant
);
// Message de chat
const atkSkill = getAssignedSkill(this.attackerCombatant);
await sendResultToChat({
attackerName: this.attackerCombatant?.name ?? "?",
defenderName: this.defenderCombatant?.name ?? "?",
skillName: atkSkill?.name ?? "?",
result: this._clashResult,
});
ui.notifications.info(game.i18n.localize("SOTC_DW.DamageApplied"));
// Vider le cache et réinitialiser l'état local
_rollCache.delete(this.attackerCombatantId);
this._atkRolls = null;
this._defRolls = null;
this._clashResult = null;
this.render(false);
}
}

View file

@ -0,0 +1,212 @@
/**
* sotc-damage-wizard | main.js
*
* Point d'entrée du module.
*
* Stratégie d'intégration SBSA :
* On utilise le hook "renderApplication" pour détecter quand SBSA rend
* son panneau et on injecte dynamiquement le bouton "⚔ Damage Wizard"
* dans la section de la compétence assignée.
*
* Compatibilité sotc-clash-arrow :
* clash-arrow écoute aussi renderApplication sur SBSA pour injecter
* ses propres boutons debug. On utilise un attribut data pour éviter
* les injections en double.
*
* Condition d'activation du bouton :
* - dieState === 3 (compétence assignée)
* - Une cible est détectable (ArrowStore ou game.user.targets)
*/
import { DamageWizardApp, _rollCache } from "./DamageWizardApp.js";
import { getTargetInfo } from "./ArrowBridge.js";
import { getAssignedSkill } from "./DamageEngine.js";
const SBSA_MODULE = "sotc-battle-show-ability";
/* ------------------------------------------------------------------ */
/* Init */
/* ------------------------------------------------------------------ */
Hooks.once("init", () => {
console.log("sotc-damage-wizard | Initialisation");
loadTemplates([
"modules/sotc-damage-wizard/templates/damage-wizard.hbs"
]);
// "eq" est déjà enregistré par le système SotC — on enregistre "gt" si absent
if (!Handlebars.helpers["gt"]) {
Handlebars.registerHelper("gt", (a, b) => a > b);
}
});
/* ------------------------------------------------------------------ */
/* Détection d'un panneau SBSA */
/* ------------------------------------------------------------------ */
/**
* Retourne true si l'Application donnée est un SpeedDiePanelApp SBSA.
* Trois méthodes de détection pour couvrir tous les cas.
*/
function _isSBSAPanel(app) {
// 1. Instance directe via l'API publique SBSA
const sbsaApi = game.modules.get(SBSA_MODULE)?.api;
if (sbsaApi?.SpeedDiePanelApp && app instanceof sbsaApi.SpeedDiePanelApp) return true;
// 2. L'id de l'Application FoundryVTT
if (app.id === "sotc-bsa-panel") return true;
// 3. Nom du constructeur (robuste si l'API n'est pas encore montée)
if (app.constructor?.name === "SpeedDiePanelApp") return true;
return false;
}
/* ------------------------------------------------------------------ */
/* Injection du bouton dans le panneau SBSA */
/* ------------------------------------------------------------------ */
Hooks.on("renderApplication", (app, html, data) => {
if (!_isSBSAPanel(app)) return;
_injectDamageButton(app, html);
});
/**
* Injecte le bouton "⚔ Damage Wizard" dans le panneau SBSA.
* N'injecte que si une compétence est assignée (dieState === 3).
* Utilise data-dw-injected pour éviter les doublons lors des re-renders.
*/
function _injectDamageButton(app, html) {
const combatantId = app.combatantId;
if (!combatantId) return;
const combatant = game.combat?.combatants.get(combatantId);
if (!combatant) return;
// Vérifier qu'une compétence est assignée
const skill = getAssignedSkill(combatant);
if (!skill) return;
// Retirer un ancien bouton si déjà présent (re-render propre)
html.find(".dw-open-btn-wrapper").remove();
// Construire le bouton
const $wrapper = $(`
<div class="dw-open-btn-wrapper" data-dw-injected="true"
style="padding: 4px 8px;">
<button class="dw-open-btn" data-combatant-id="${combatantId}"
title="${game.i18n.localize('SOTC_DW.Title')}">
⚔ ${game.i18n.localize("SOTC_DW.Title")}
</button>
</div>
`);
// Insérer après la section compétence assignée, ou en haut du panneau
const $assigned = html.find(".bsa-assigned-section");
if ($assigned.length) {
$assigned.after($wrapper);
} else {
html.find(".sotc-bsa-panel").prepend($wrapper);
}
// Listener
$wrapper.find(".dw-open-btn").on("click", ev => {
ev.stopPropagation();
_openDamageWizard(ev.currentTarget.dataset.combatantId);
});
}
/* ------------------------------------------------------------------ */
/* Ouverture du DamageWizardApp */
/* ------------------------------------------------------------------ */
function _openDamageWizard(attackerCombatantId) {
const attackerCombatant = game.combat?.combatants.get(attackerCombatantId);
if (!attackerCombatant) {
ui.notifications.warn("sotc-damage-wizard : Combatant introuvable.");
return;
}
// Compétence assignée requise
const skill = getAssignedSkill(attackerCombatant);
if (!skill) {
ui.notifications.warn(game.i18n.localize("SOTC_DW.NoSkill"));
return;
}
// Cible via ArrowBridge (ArrowStore ou game.user.targets)
const { defenderCombatant, isClash } = getTargetInfo(attackerCombatant);
if (!defenderCombatant) {
ui.notifications.warn(game.i18n.localize("SOTC_DW.NoTarget"));
return;
}
// Réutiliser une fenêtre existante si possible
const existing = Object.values(ui.windows).find(
w => w instanceof DamageWizardApp
&& w.attackerCombatantId === attackerCombatantId
&& w.defenderCombatantId === defenderCombatant.id
);
if (existing) {
existing.render(true);
existing.bringToTop();
return;
}
// Titre
const title = isClash
? `${game.i18n.localize("SOTC_DW.TitleClash")} — ${attackerCombatant.name} ⚔ ${defenderCombatant.name}`
: `${game.i18n.localize("SOTC_DW.Title")} — ${attackerCombatant.name} → ${defenderCombatant.name}`;
const wizard = new DamageWizardApp(
attackerCombatantId,
defenderCombatant.id,
isClash,
{ title }
);
wizard.render(true);
}
/* ------------------------------------------------------------------ */
/* Réactivité : rafraîchir le bouton si le dé change d'état */
/* ------------------------------------------------------------------ */
Hooks.on("updateCombatant", (combatant, changes, options, userId) => {
if (game.system.id !== "sotc") return;
for (const win of Object.values(ui.windows)) {
if (_isSBSAPanel(win) && win.combatantId === combatant.id) {
win.render(false);
break;
}
}
});
/* ------------------------------------------------------------------ */
/* Nettoyage en fin de combat / nouveau round */
/* ------------------------------------------------------------------ */
Hooks.on("deleteCombat", () => {
_rollCache.clear();
for (const win of Object.values(ui.windows)) {
if (win instanceof DamageWizardApp) win.close();
}
});
Hooks.on("combatRound", () => {
_rollCache.clear();
});
/* ------------------------------------------------------------------ */
/* API publique */
/* ------------------------------------------------------------------ */
Hooks.once("ready", () => {
game.modules.get("sotc-damage-wizard").api = {
DamageWizardApp,
openDamageWizard: _openDamageWizard
};
console.log("sotc-damage-wizard | Module pret.");
});