/** * 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" ? ` VS ${m.oppType} ${m.oppPower}` : ` (sans opposition)`; const icon = `systems/sotc/assets/dice types/${m.dieType}.png`; return `
${m.dieType} ${m.power}${vs} ${m.effects.join(", ")}
`; }).join(""); const defenderSection = (atkHpDamage > 0 || atkStaggerDamage > 0) ? `

🎯 ${defenderName} → HP -${atkHpDamage}, Stagger -${atkStaggerDamage}

` : `

🎯 ${defenderName} → Aucun dégât

`; const attackerSection = (defHpDamage > 0 || defStaggerDamage > 0) ? `

🎯 ${attackerName} → HP -${defHpDamage}, Stagger -${defStaggerDamage}

` : ""; const regainSection = (defStaggerRegain > 0 || atkStaggerRegain > 0) ? `

💚 Stagger regain : ${defStaggerRegain > 0 ? `${defenderName} +${defStaggerRegain} ` : ""}${atkStaggerRegain > 0 ? `${attackerName} +${atkStaggerRegain}` : ""}

` : ""; const peSection = (atkPE > 0 || defPE > 0) ? `

💜 PE : ${attackerName} +${atkPE}, ${defenderName} +${defPE}

` : ""; const content = `

⚔ ${skillName ?? "?"} — ${attackerName} → ${defenderName}

${matchupLines}

${defenderSection} ${attackerSection} ${regainSection} ${peSection}
`; await ChatMessage.create({ speaker: ChatMessage.getSpeaker(), content }); }